├── .eslintrc.json
├── app
├── favicon.ico
├── page.tsx
├── doc
│ ├── layout.tsx
│ ├── doc.md
│ └── page.tsx
├── layout.tsx
├── blog
│ ├── page.tsx
│ └── [slug]
│ │ └── page.tsx
└── globals.css
├── next.config.mjs
├── .env.local.example
├── postcss.config.mjs
├── lib
├── utils.ts
├── directory-data.ts
├── static-data.ts
├── search-utils.ts
└── raindrop-api.ts
├── components.json
├── .gitignore
├── tsconfig.json
├── package.json
├── public
└── next.svg
├── components
├── common
│ ├── Faq.tsx
│ ├── Statement.tsx
│ ├── FilterSection.tsx
│ ├── DirectoryItem.tsx
│ ├── SearchBar.tsx
│ └── Header.tsx
├── ui
│ ├── button.tsx
│ └── accordion.tsx
└── Base.tsx
├── README_zh-CN.md
├── content
└── blog
│ └── first-blog.md
├── README.md
└── tailwind.config.ts
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuilenren/boks-888/main/app/favicon.ico
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: 'export',
4 | };
5 |
6 | export default nextConfig;
7 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | # Raindrop.io API token
2 | # Get your token from https://app.raindrop.io/settings/integrations
3 | RAINDROP_API_TOKEN=your_raindrop_api_token_here
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/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": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
--------------------------------------------------------------------------------
/lib/directory-data.ts:
--------------------------------------------------------------------------------
1 | export interface DirectoryItemProps {
2 | id: string;
3 | title: string;
4 | tags: string[];
5 | excerpt: string;
6 | link: string;
7 | cover: string;
8 | created: string;
9 | media: {link: string, type: string}[];
10 | note: string;
11 | }
12 |
13 | // These will be populated from the API
14 | export const directoryItems: DirectoryItemProps[] = [];
15 | export const categories: string[] = [];
16 | export const allTags: string[] = [];
17 | export const allTechnologies: string[] = [];
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Base } from '@/components/Base';
2 | import { getStaticDirectoryData } from '@/lib/static-data';
3 |
4 | export const revalidate = 3600; // Revalidate every hour
5 |
6 | export default async function Home() {
7 | // Fetch data at build time
8 | const { directoryItems, allTags } = await getStaticDirectoryData();
9 |
10 | return (
11 |
12 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/.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 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.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 | # Tencent Cloud Edge Functions
39 | .env
40 | .edgeone
41 | edgeone.json
42 |
43 | debug-raindrop-items.json
44 | docs/*
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/app/doc/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "../globals.css";
4 |
5 | const inter = Inter({
6 | subsets: ["latin"],
7 | variable: "--font-inter",
8 | display: "swap"
9 | });
10 |
11 | export const metadata: Metadata = {
12 | title: "使用文档 | Raindrop.io 作为无头CMS",
13 | description: "如何使用 Raindrop.io 作为无头CMS来生成你自己的内容目录页面",
14 | keywords: ["Raindrop.io", "无头CMS", "文档", "教程"],
15 | authors: [{ name: "Indie Hacker Tools" }],
16 | creator: "Indie Hacker Tools",
17 | };
18 |
19 | export default function DocLayout({
20 | children,
21 | }: Readonly<{
22 | children: React.ReactNode;
23 | }>) {
24 | return (
25 |
26 |
27 | {children}
28 |
29 |
30 | );
31 | }
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({
6 | subsets: ["latin"],
7 | variable: "--font-inter",
8 | display: "swap"
9 | });
10 |
11 | export const metadata: Metadata = {
12 | title: "独立开发者出海工具箱 | Indie Hacker Tools",
13 | description: "灵感、设计、开发、增长、变现、工具、资源等各类资源",
14 | keywords: ["独立开发者", "出海工具", "indie hacker", "tools"],
15 | authors: [{ name: "Indie Hacker Tools" }],
16 | creator: "Indie Hacker Tools",
17 | };
18 |
19 | export default function RootLayout({
20 | children,
21 | }: Readonly<{
22 | children: React.ReactNode;
23 | }>) {
24 | return (
25 |
26 |
27 | {children}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@jridgewell/gen-mapping": "0.3.5",
13 | "@radix-ui/react-accordion": "^1.2.0",
14 | "@radix-ui/react-icons": "^1.3.0",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "@tailwindcss/typography": "^0.5.16",
17 | "class-variance-authority": "^0.7.0",
18 | "clsx": "^2.1.1",
19 | "gray-matter": "^4.0.3",
20 | "lucide-react": "^0.438.0",
21 | "next": "14.2.5",
22 | "next-mdx-remote": "^5.0.0",
23 | "react": "^18",
24 | "react-dom": "^18",
25 | "rehype-external-links": "^3.0.0",
26 | "rehype-stringify": "^10.0.1",
27 | "remark": "^15.0.1",
28 | "remark-html": "^16.0.1",
29 | "remark-parse": "^11.0.0",
30 | "remark-rehype": "^11.1.1",
31 | "tailwind-merge": "^2.5.2",
32 | "tailwindcss-animate": "^1.0.7"
33 | },
34 | "devDependencies": {
35 | "@types/node": "^20",
36 | "@types/react": "^18",
37 | "@types/react-dom": "^18",
38 | "eslint": "^8",
39 | "eslint-config-next": "14.2.5",
40 | "postcss": "^8",
41 | "tailwindcss": "^3.4.1",
42 | "typescript": "^5"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/static-data.ts:
--------------------------------------------------------------------------------
1 | import { DirectoryItemProps } from './directory-data';
2 | import { fetchRaindrops, mapRaindropsToDirectoryItems, extractAllTags } from './raindrop-api';
3 | import fs from 'fs';
4 | import path from 'path';
5 |
6 | export interface StaticDirectoryData {
7 | directoryItems: DirectoryItemProps[];
8 | allTags: string[];
9 | }
10 |
11 | export async function getStaticDirectoryData(): Promise {
12 | try {
13 | // You can specify a collection ID here if needed
14 | const collectionId = 0; // 0 means all bookmarks
15 | const response = await fetchRaindrops(collectionId);
16 |
17 | // Write response items to a local file for easier inspection
18 | if (process.env.NODE_ENV === 'development') {
19 | const debugFilePath = path.join(process.cwd(), 'debug-raindrop-items.json');
20 | fs.writeFileSync(debugFilePath, JSON.stringify(response.items, null, 2), 'utf8');
21 | console.log(`Debug data written to: ${debugFilePath}`);
22 | }
23 |
24 | // Map Raindrop items to DirectoryItemProps
25 | const items = mapRaindropsToDirectoryItems(response.items);
26 |
27 | // Extract all tags
28 | const tags = extractAllTags(items);
29 |
30 | return {
31 | directoryItems: items,
32 | allTags: tags
33 | };
34 | } catch (err) {
35 | console.error('Error fetching data from Raindrop.io:', err);
36 | throw new Error('Failed to fetch data from Raindrop.io. Please check your API token and try again.');
37 | }
38 | }
--------------------------------------------------------------------------------
/components/common/Faq.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Accordion,
4 | AccordionContent,
5 | AccordionItem,
6 | AccordionTrigger,
7 | } from '@/components/ui/accordion';
8 |
9 | export default function Faq() {
10 | const items = [
11 | {
12 | trigger: 'Where is this website hosted?',
13 | content: (
14 | <>
15 | This webpage is hosted on{' '}
16 |
22 | Edgeone Pages
23 |
24 | , a free global acceleration static website hosting service. This
25 | webpage is open source on{' '}
26 |
31 | GitHub
32 |
33 | .
34 | >
35 | ),
36 | },
37 | ].map((item) => ({ ...item, id: item.trigger }));
38 |
39 | return (
40 |
41 |
item.id)}>
42 | {items.map((item) => (
43 |
44 | {item.trigger}
45 | {item.content}
46 |
47 | ))}
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/README_zh-CN.md:
--------------------------------------------------------------------------------
1 | # 导航站点模板 - 以独立开发者出海工具箱为例
2 |
3 | 这是一个基于 EdgeOne Pages 和 Raindrop.io API 构建的导航站点模板。本示例以"独立开发者出海工具箱"为主题,展示了如何快速搭建一个美观、实用的导航网站。
4 |
5 | ## 特色功能
6 |
7 | - 🎨 现代化 UI 设计,支持亮色/暗色模式
8 | - 🏷️ 基于标签的智能分类系统
9 | - 🔍 强大的模糊搜索功能
10 | - 📱 完全响应式设计
11 | - ⚡ 基于 Next.js 的高性能架构
12 | - 🔄 实时数据同步(通过 Raindrop.io)
13 |
14 | ## 一键部署
15 |
16 | [](https://console.cloud.tencent.com/edgeone/pages/new?template=directory)
17 |
18 | ## 开始使用
19 |
20 | ### 1. 设置 Raindrop.io
21 |
22 | 1. 在 [Raindrop.io](https://raindrop.io) 创建账号
23 | 2. 访问 [集成设置页面](https://app.raindrop.io/settings/integrations) 创建应用
24 | 3. 生成并复制 API 令牌
25 | 4. 在项目根目录创建 `.env.local` 文件并添加令牌:
26 |
27 | ```
28 | NEXT_PUBLIC_RAINDROP_API_TOKEN=your_raindrop_api_token_here
29 | ```
30 |
31 | 可以参考 `.env.local.example` 文件作为模板。
32 |
33 | ### 2. 添加你的导航内容
34 |
35 | 1. 在 Raindrop.io 中添加书签
36 | 2. 为每个书签添加合适的标签进行分类
37 | 3. 添加描述和笔记以提供更多上下文信息
38 | 4. 可选:自定义封面图片
39 |
40 | ### 3. 本地开发
41 |
42 | ```bash
43 | # 安装依赖
44 | npm install
45 | # 或
46 | yarn
47 | # 或
48 | pnpm install
49 | # 或
50 | bun install
51 |
52 | # 启动开发服务器
53 | npm run dev
54 | # 或
55 | yarn dev
56 | # 或
57 | pnpm dev
58 | # 或
59 | bun dev
60 | ```
61 |
62 | 访问 [http://localhost:3000](http://localhost:3000) 查看你的导航站点。
63 |
64 | ## 自定义主题
65 |
66 | 本项目使用了 Tailwind CSS 进行样式设计。你可以通过修改以下文件来自定义主题:
67 |
68 | - `app/globals.css` - 全局样式
69 | - `components/Base.tsx` - 主要布局组件
70 | - `components/common/*` - 通用组件
71 |
72 | ## 了解更多
73 |
74 | - [Next.js 文档](https://nextjs.org/docs)
75 | - [Raindrop.io API 文档](https://developer.raindrop.io)
76 | - [Tailwind CSS 文档](https://tailwindcss.com/docs)
77 |
--------------------------------------------------------------------------------
/components/common/Statement.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | import React from 'react';
3 |
4 | interface StatementItem {
5 | question: string;
6 | answer: any;
7 | }
8 |
9 | const datas: StatementItem[] = [
10 | {
11 | question: 'Where is this website hosted?',
12 | answer: (
13 | <>
14 |
15 | This webpage is hosted on{' '}
16 |
21 | OpenEdge Pages
22 |
23 | , a free global acceleration static website hosting service. This
24 | webpage is open source on{' '}
25 |
30 | GitHub
31 |
32 | .
33 |
34 | >
35 | ),
36 | },
37 | ];
38 |
39 | const Statement: React.FC = () => {
40 | return (
41 |
42 |
43 | {datas.map((item, index) => (
44 |
45 | {item.question && (
46 |
47 | {item.question}
48 |
49 | )}
50 | {item.answer}
51 |
52 | ))}
53 |
54 |
55 | );
56 | };
57 |
58 | export default Statement;
59 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/lib/search-utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Performs a fuzzy search on a string
3 | * @param text The text to search in
4 | * @param query The query to search for
5 | * @returns A score between 0 and 1, where 1 is a perfect match
6 | */
7 | export function fuzzySearch(text: string, query: string): number {
8 | if (!text || !query) return 0;
9 |
10 | const lowerText = text.toLowerCase();
11 | const lowerQuery = query.toLowerCase();
12 |
13 | // Exact match gets highest score
14 | if (lowerText.includes(lowerQuery)) {
15 | // Prioritize matches at the beginning of the text or at word boundaries
16 | if (lowerText.startsWith(lowerQuery)) {
17 | return 1;
18 | }
19 |
20 | // Check if the match is at a word boundary
21 | const index = lowerText.indexOf(lowerQuery);
22 | if (index > 0 && (lowerText[index - 1] === ' ' || lowerText[index - 1] === '-' || lowerText[index - 1] === '_')) {
23 | return 0.9;
24 | }
25 |
26 | return 0.8;
27 | }
28 |
29 | // Check for partial matches (all characters in order but with other characters in between)
30 | let textIndex = 0;
31 | let queryIndex = 0;
32 | let matchCount = 0;
33 |
34 | while (textIndex < lowerText.length && queryIndex < lowerQuery.length) {
35 | if (lowerText[textIndex] === lowerQuery[queryIndex]) {
36 | matchCount++;
37 | queryIndex++;
38 | }
39 | textIndex++;
40 | }
41 |
42 | // Calculate a score based on how many characters matched and how close they are
43 | if (matchCount === lowerQuery.length) {
44 | return 0.5 + (matchCount / lowerText.length) * 0.3;
45 | }
46 |
47 | // Calculate a partial match score
48 | return (matchCount / lowerQuery.length) * 0.4;
49 | }
50 |
51 | /**
52 | * Extracts the domain from a URL
53 | * @param url The URL to extract the domain from
54 | * @returns The domain or the original URL if it's not a valid URL
55 | */
56 | export function extractDomain(url: string): string {
57 | try {
58 | const urlObj = new URL(url);
59 | return urlObj.hostname;
60 | } catch (e) {
61 | return url;
62 | }
63 | }
--------------------------------------------------------------------------------
/content/blog/first-blog.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: '打造专属导航站:5分钟搭建你的创意工具箱 🚀'
3 | date: '2025-03-18'
4 | description: '无需编程经验,轻松搭建个人导航站!从 Raindrop.io 收藏到精美导航站,让你的收藏夹华丽转身。'
5 | tags: ['导航站', '个人项目', '工具分享', 'Raindrop', 'EdgeOne']
6 | ---
7 |
8 | 还在为收藏夹里的链接杂乱无章而烦恼吗?🤔
9 | 还在想着怎么搭建一个漂亮的导航网站来展示你的收藏吗?
10 | 今天,让我们一起来打造你的专属导航站!不需要任何编程经验,只需要 5 分钟,就能让你的收藏夹华丽转身!
11 |
12 | ## 为什么要建立自己的导航站? 🎯
13 |
14 | 想象一下,你有一个:
15 |
16 | - 🌈 设计精美的个人导航站
17 | - 🔍 支持实时搜索的工具箱
18 | - 🏷️ 智能标签分类系统
19 | - 🌙 优雅的深色模式
20 | - ⚡ 全球加速的访问体验
21 |
22 | 最重要的是,这一切都是**免费**的!而且维护起来超级简单!
23 |
24 | ## 快速开始 🚀
25 |
26 | ### 第一步:准备你的收藏库
27 |
28 | 1. 注册 [Raindrop.io](https://raindrop.io) 账号(免费的!)
29 | 2. 开始收藏你喜欢的网站、工具和资源
30 | 3. 给收藏添加标签,让内容更有条理
31 |
32 | > 💡 小贴士:好的标签系统能让你的导航站更加专业!
33 |
34 | ### 第二步:一键部署
35 |
36 | 1. 点击 [立即部署](https://edgeone.ai/pages/templates/directory) 按钮
37 | 2. 登录你的腾讯云账号(没有?免费注册一个!)
38 | 3. 获取 Raindrop API Token([点这里](https://app.raindrop.io/settings/integrations))
39 | 4. 填入配置信息,点击部署
40 |
41 | 就这么简单!你的导航站就搭建完成了! 🎉
42 |
43 | ## 个性化定制 🎨
44 |
45 | 想让你的导航站与众不同?这里有一些小技巧:
46 |
47 | 1. **主题定制**
48 |
49 | - 修改颜色方案
50 | - 自定义字体
51 | - 添加个性化图标
52 |
53 | 2. **内容组织**
54 |
55 | - 创建精心设计的分类
56 | - 设置重点推荐区域
57 | - 添加个性化描述
58 |
59 | 3. **功能增强**
60 | - 添加访问统计
61 | - 集成评论系统
62 | - 添加社交分享
63 |
64 | ## 常见问题解答 🤔
65 |
66 | ### Q: 需要会编程吗?
67 |
68 | A: 完全不需要!整个过程就像注册一个新的社交媒体账号一样简单。
69 |
70 | ### Q: 会有广告吗?
71 |
72 | A: 不会!这是你的专属空间,完全没有广告。
73 |
74 | ### Q: 可以迁移我的书签吗?
75 |
76 | A: 当然可以!Raindrop.io 支持从 Chrome、Firefox 等浏览器导入书签。
77 |
78 | ## 成功案例 🌟
79 |
80 | ### 设计师 Sarah 的工具箱
81 |
82 | > "把我的设计资源整理得井井有条,客户都说很专业!"
83 |
84 | ### 开发者 Tom 的代码库
85 |
86 | > "再也不用担心找不到之前收藏的那个超赞的库了!"
87 |
88 | ### 自媒体 Linda 的资源站
89 |
90 | > "给粉丝们一个专业的资源导航,涨粉效果特别好!"
91 |
92 | ## 开始行动! 🎬
93 |
94 | 还在等什么?现在就开始打造你的专属导航站吧!
95 |
96 | 1. 📝 整理你的收藏
97 | 2. 🚀 一键部署
98 | 3. 🎨 个性化定制
99 | 4. 🌟 分享给朋友
100 |
101 | [立即部署你的导航站](https://edgeone.ai/pages/templates/directory) →
102 |
103 | ## 结语 🌈
104 |
105 | 创建一个专业的导航站从未如此简单!让我们一起打造一个更有组织、更美观的网络空间。
106 |
107 | 现在就开始你的导航站之旅吧! 🚀
108 |
--------------------------------------------------------------------------------
/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDownIcon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
56 |
57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
58 |
--------------------------------------------------------------------------------
/app/doc/doc.md:
--------------------------------------------------------------------------------
1 | # 如何使用这个导航站点模板
2 |
3 | ## 项目介绍
4 |
5 | 这是一个基于 Raindrop.io 构建的导航站点模板,以"独立开发者出海工具箱"为示例主题。通过这个模板,你可以快速搭建自己的导航网站,展示和分享你收集的各类资源。
6 |
7 | ## 为什么选择 Raindrop.io?
8 |
9 | Raindrop.io 不仅仅是一个书签管理工具,在本项目中,我们将其用作无头 CMS(内容管理系统)。它具有以下优势:
10 |
11 | - 强大的标签系统,便于内容分类
12 | - 简洁的用户界面,易于管理内容
13 | - 可靠的 API,支持实时数据同步
14 | - 免费计划即可满足基本需求
15 | - 支持多端同步,随时随地管理内容
16 |
17 | ## 开始使用
18 |
19 | ### 1. 设置 Raindrop.io
20 |
21 | 1. 注册 [Raindrop.io](https://raindrop.io) 账号
22 | 2. 访问[集成页面](https://app.raindrop.io/settings/integrations)
23 | 3. 创建新的应用并获取 API 令牌
24 | 4. 在项目中配置环境变量:
25 |
26 | ```bash
27 | RAINDROP_API_TOKEN=your_api_token_here
28 | ```
29 |
30 | ### 2. 组织你的内容
31 |
32 | #### 使用标签系统
33 |
34 | 标签是组织内容的核心。建议:
35 |
36 | - 使用有意义的标签名称
37 | - 为每个资源添加多个相关标签
38 | - 保持标签体系的一致性
39 | - 定期整理和更新标签
40 |
41 | #### 添加资源描述
42 |
43 | 为每个资源添加详细的描述信息:
44 |
45 | - 简要说明资源的用途
46 | - 列出主要特点或功能
47 | - 添加使用建议或注意事项
48 | - 标注资源的适用场景
49 |
50 | #### 设置封面图片
51 |
52 | - Raindrop.io 会自动抓取网站封面
53 | - 也可以手动上传自定义图片
54 | - 建议使用清晰、相关的图片
55 | - 保持图片风格的一致性
56 |
57 | ## 部署和维护
58 |
59 | ### 快速部署
60 |
61 | 1. 点击 [EdgeOne Pages](https://edgeone.ai/pages/templates/directory) 一键部署
62 | 2. 配置环境变量 `RAINDROP_API_TOKEN`
63 | 3. 等待部署完成即可访问
64 |
65 | ### 自定义站点
66 |
67 | #### 修改主题样式
68 |
69 | 可以通过修改以下文件自定义外观:
70 |
71 | - `app/globals.css` - 全局样式
72 | - `components/Base.tsx` - 主要布局
73 | - `components/common/*` - 通用组件
74 |
75 | #### 调整内容展示
76 |
77 | 在 `lib/static-data.ts` 中可以:
78 |
79 | ```typescript
80 | // 修改集合 ID(默认为 0,即所有书签)
81 | const collectionId = 0; // 改为你的集合 ID
82 | ```
83 |
84 | ## 最佳实践
85 |
86 | ### 内容管理
87 |
88 | - 定期更新和维护资源
89 | - 删除过时或无效的链接
90 | - 保持分类的清晰和合理
91 | - 适时调整标签体系
92 |
93 | ### 用户体验
94 |
95 | - 确保资源描述清晰有用
96 | - 选择合适的封面图片
97 | - 保持分类的直观性
98 | - 定期检查链接可用性
99 |
100 | ### 性能优化
101 |
102 | - 适当控制资源数量
103 | - 优化图片大小和质量
104 | - 定期清理无用数据
105 | - 监控站点性能指标
106 |
107 | ## 常见问题
108 |
109 | ### 如何添加更多功能?
110 |
111 | 1. 查阅 [Raindrop API 文档](https://developer.raindrop.io)
112 | 2. 了解可用的 API 功能
113 | 3. 根据需求扩展项目
114 | 4. 测试新功能的性能影响
115 |
116 | ### 遇到问题怎么办?
117 |
118 | 1. 检查 API 令牌配置
119 | 2. 查看浏览器控制台错误
120 | 3. 检查网络请求状态
121 | 4. 提交 Issue 寻求帮助
122 |
123 | ## 更新日志
124 |
125 | 我们会定期更新模板,添加新功能和改进。请关注项目仓库获取最新更新。
126 |
--------------------------------------------------------------------------------
/components/common/FilterSection.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 |
3 | interface FilterSectionProps {
4 | onFilterChange?: (filters: {
5 | tag?: string;
6 | }) => void;
7 | onReset?: () => void;
8 | selectedTag?: string;
9 | allTags: string[];
10 | }
11 |
12 | export const FilterSection: React.FC = ({
13 | onFilterChange,
14 | onReset,
15 | selectedTag = '',
16 | allTags = []
17 | }) => {
18 | // Remove the local state and useEffect since we're now receiving tags as props
19 |
20 | const handleTagClick = (tag: string) => {
21 | const newTag = selectedTag === tag ? '' : tag;
22 |
23 | if (onFilterChange) {
24 | onFilterChange({
25 | tag: newTag || undefined
26 | });
27 | }
28 | };
29 |
30 | const handleReset = () => {
31 | if (onReset) {
32 | onReset();
33 | }
34 | };
35 |
36 | return (
37 |
38 |
39 |
49 |
50 | {allTags.map((tag, index) => (
51 |
62 | ))}
63 |
64 |
65 | );
66 | };
--------------------------------------------------------------------------------
/components/common/DirectoryItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DirectoryItemProps } from '@/lib/directory-data';
3 | import { ExternalLink } from 'lucide-react';
4 |
5 | export const DirectoryItem: React.FC = ({
6 | title,
7 | tags,
8 | excerpt,
9 | link,
10 | media,
11 | note,
12 | cover,
13 | }) => {
14 | return (
15 |
22 |
23 | {cover && (
24 |
25 |

31 |
32 | )}
33 |
34 |
35 | {title}
36 |
37 |
38 |
39 | {tags.map((tag, index) => (
40 |
44 | {tag}
45 |
46 | ))}
47 |
48 |
49 |
50 | {note || excerpt || ''}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Directory Site Template - Using "Indie Developers' Toolbox" as an Example
2 |
3 | This is a Directory site template built with EdgeOne Pages and the Raindrop.io API. This example uses the theme "Indie Developers' Toolbox for Going Global" to demonstrate how to quickly build a beautiful and practical Directory website.
4 |
5 | ## Features
6 |
7 | - 🎨 Modern UI design with light/dark mode support
8 | - 🏷️ Smart, tag-based categorization system
9 | - 🔍 Powerful fuzzy search functionality
10 | - 📱 Fully responsive design
11 | - ⚡ High-performance architecture based on Next.js
12 | - 🔄 Real-time data synchronization (via Raindrop.io)
13 |
14 | ## Deploy
15 |
16 | [](https://edgeone.ai/pages/new?from=github&template=directory)
17 |
18 | More Templates: [EdgeOne Pages](https://edgeone.ai/pages/templates)
19 |
20 | ## Getting Started
21 |
22 | ### 1. Set up Raindrop.io
23 |
24 | 1. Create an account on [Raindrop.io](https://raindrop.io).
25 | 2. Visit the [Integrations settings page](https://app.raindrop.io/settings/integrations) to create an application.
26 | 3. Generate and copy the API token.
27 | 4. Create a `.env.local` file in the project's root directory and add the token:
28 |
29 | ```
30 | NEXT_PUBLIC_RAINDROP_API_TOKEN=your_raindrop_api_token_here
31 | ```
32 |
33 | You can use the `.env.local.example` file as a template.
34 |
35 | ### 2. Add Your Navigation Content
36 |
37 | 1. Add bookmarks in Raindrop.io.
38 | 2. Add appropriate tags to each bookmark for categorization.
39 | 3. Add descriptions and notes to provide more context.
40 | 4. Optional: Customize cover images.
41 |
42 | ### 3. Local Development
43 |
44 | ```bash
45 | # Install dependencies
46 | npm install
47 | # or
48 | yarn
49 | # or
50 | pnpm install
51 | # or
52 | bun install
53 |
54 | # Start the development server
55 | npm run dev
56 | # or
57 | yarn dev
58 | # or
59 | pnpm dev
60 | # or
61 | bun dev
62 | ```
63 |
64 | Visit [http://localhost:3000](http://localhost:3000) to view your navigation site.
65 |
66 | ## Customizing the Theme
67 |
68 | This project uses Tailwind CSS for styling. You can customize the theme by modifying the following files:
69 |
70 | - `app/globals.css` - Global styles
71 | - `components/Base.tsx` - Main layout component
72 | - `components/common/*` - Common components
73 |
74 | ## Learn More
75 |
76 | - [Next.js Documentation](https://nextjs.org/docs)
77 | - [Raindrop.io API Documentation](https://developer.raindrop.io)
78 | - [Tailwind CSS Documentation](https://tailwindcss.com/docs)
79 |
--------------------------------------------------------------------------------
/lib/raindrop-api.ts:
--------------------------------------------------------------------------------
1 | import { DirectoryItemProps } from './directory-data';
2 |
3 | // Raindrop API response types
4 | export interface RaindropItem {
5 | _id: string;
6 | title: string;
7 | excerpt: string;
8 | link: string;
9 | tags: string[];
10 | cover: string;
11 | created: string;
12 | type: string;
13 | domain: string;
14 | note: string;
15 | media: {link: string, type: string}[];
16 | }
17 |
18 | export interface RaindropResponse {
19 | items: RaindropItem[];
20 | count: number;
21 | collectionId: number;
22 | }
23 |
24 | // Function to fetch bookmarks from Raindrop.io API
25 | export async function fetchRaindrops(collectionId = 0): Promise {
26 | // The API token should be stored in environment variables
27 | // Using process.env instead of process.env.NEXT_PUBLIC_ for server-side rendering
28 | const token = process.env.RAINDROP_API_TOKEN;
29 |
30 | if (!token) {
31 | throw new Error('Raindrop API token is not defined');
32 | }
33 |
34 | const perPage = 50; // Maximum items per page allowed by Raindrop API
35 | let allItems: RaindropItem[] = [];
36 | let page = 0;
37 | let hasMore = true;
38 |
39 | while (hasMore) {
40 | const response = await fetch(`https://api.raindrop.io/rest/v1/raindrops/${collectionId}?page=${page}&perPage=${perPage}`, {
41 | headers: {
42 | 'Authorization': `Bearer ${token}`,
43 | 'Content-Type': 'application/json'
44 | }
45 | });
46 |
47 | if (!response.ok) {
48 | throw new Error(`Failed to fetch raindrops: ${response.statusText}`);
49 | }
50 |
51 | const data = await response.json();
52 | allItems = [...allItems, ...data.items];
53 |
54 | // Check if there are more items to fetch based on total count
55 | console.log(`Fetched ${data.items.length} items, total: ${data.count}`);
56 | hasMore = allItems.length < data.count;
57 | page++;
58 | }
59 |
60 | return {
61 | items: allItems,
62 | count: allItems.length,
63 | collectionId
64 | };
65 | }
66 |
67 | // Function to convert Raindrop items to DirectoryItemProps
68 | export function mapRaindropsToDirectoryItems(raindrops: RaindropItem[]): DirectoryItemProps[] {
69 | return raindrops.map(item => ({
70 | id: item._id,
71 | title: item.title,
72 | note: item.note,
73 | tags: item.tags,
74 | excerpt: item.excerpt,
75 | link: item.link,
76 | cover: item.cover,
77 | created: item.created,
78 | media: item.media,
79 | }));
80 | }
81 |
82 | // Function to get all unique tags from raindrops
83 | export function extractAllTags(items: DirectoryItemProps[]): string[] {
84 | return Array.from(new Set(items.flatMap(item => item.tags))).sort();
85 | }
86 |
--------------------------------------------------------------------------------
/app/doc/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import { promises as fs } from 'fs';
4 | import path from 'path';
5 | import { MDXRemote } from 'next-mdx-remote/rsc';
6 |
7 | const CustomLink = (props: React.AnchorHTMLAttributes) => {
8 | return ;
9 | };
10 |
11 | export default async function DocPage() {
12 | const markdownPath = path.join(process.cwd(), 'app/doc/doc.md');
13 | const markdownContent = await fs.readFile(markdownPath, 'utf8');
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
50 |
51 | 导航站模板
52 |
53 |
54 |
55 |
59 | 返回首页
60 |
61 |
62 |
63 |
64 |
65 |
77 |
78 |
79 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/components/common/SearchBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Search, Info } from 'lucide-react';
3 |
4 | interface SearchBarProps {
5 | placeholder?: string;
6 | onSearch?: (query: string) => void;
7 | }
8 |
9 | export const SearchBar: React.FC = ({
10 | placeholder = "搜索标题/内容/域名",
11 | onSearch
12 | }) => {
13 | const [query, setQuery] = useState('');
14 | const [isFocused, setIsFocused] = useState(false);
15 | const [showInfo, setShowInfo] = useState(false);
16 |
17 | const handleSearch = () => {
18 | if (onSearch) {
19 | onSearch(query);
20 | }
21 | };
22 |
23 | const handleKeyDown = (e: React.KeyboardEvent) => {
24 | if (e.key === 'Enter') {
25 | handleSearch();
26 | }
27 | };
28 |
29 | return (
30 |
33 |
36 | setQuery(e.target.value)}
42 | onKeyDown={handleKeyDown}
43 | onFocus={() => setIsFocused(true)}
44 | onBlur={() => setIsFocused(false)}
45 | />
46 |
53 |
54 | {/* */}
61 |
62 |
63 | {query && (
64 |
65 |
按回车键搜索 "{query}"
66 |
67 | )}
68 |
69 | {showInfo && (
70 |
71 |
搜索提示
72 |
73 | - 支持模糊搜索,输入部分关键词即可匹配
74 | - 可搜索标题、内容、标签和域名
75 | - 搜索结果按相关度排序
76 | - 标题和域名匹配的结果将优先显示
77 |
78 |
79 | )}
80 |
81 | );
82 | };
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | backgroundImage: {
13 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
14 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
15 | },
16 | fontFamily: {
17 | sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
18 | serif: ['var(--font-playfair)', 'Georgia', 'serif'],
19 | },
20 | typography: {
21 | DEFAULT: {
22 | css: {
23 | maxWidth: '65ch',
24 | color: 'var(--tw-prose-body)',
25 | '[class~="lead"]': {
26 | color: 'var(--tw-prose-lead)',
27 | },
28 | },
29 | },
30 | },
31 | borderRadius: {
32 | lg: 'var(--radius)',
33 | md: 'calc(var(--radius) - 2px)',
34 | sm: 'calc(var(--radius) - 4px)'
35 | },
36 | colors: {
37 | background: 'hsl(var(--background))',
38 | foreground: 'hsl(var(--foreground))',
39 | card: {
40 | DEFAULT: 'hsl(var(--card))',
41 | foreground: 'hsl(var(--card-foreground))'
42 | },
43 | popover: {
44 | DEFAULT: 'hsl(var(--popover))',
45 | foreground: 'hsl(var(--popover-foreground))'
46 | },
47 | primary: {
48 | DEFAULT: 'hsl(var(--primary))',
49 | foreground: 'hsl(var(--primary-foreground))'
50 | },
51 | secondary: {
52 | DEFAULT: 'hsl(var(--secondary))',
53 | foreground: 'hsl(var(--secondary-foreground))'
54 | },
55 | muted: {
56 | DEFAULT: 'hsl(var(--muted))',
57 | foreground: 'hsl(var(--muted-foreground))'
58 | },
59 | accent: {
60 | DEFAULT: 'hsl(var(--accent))',
61 | foreground: 'hsl(var(--accent-foreground))'
62 | },
63 | destructive: {
64 | DEFAULT: 'hsl(var(--destructive))',
65 | foreground: 'hsl(var(--destructive-foreground))'
66 | },
67 | border: 'hsl(var(--border))',
68 | input: 'hsl(var(--input))',
69 | ring: 'hsl(var(--ring))',
70 | chart: {
71 | '1': 'hsl(var(--chart-1))',
72 | '2': 'hsl(var(--chart-2))',
73 | '3': 'hsl(var(--chart-3))',
74 | '4': 'hsl(var(--chart-4))',
75 | '5': 'hsl(var(--chart-5))'
76 | }
77 | },
78 | keyframes: {
79 | 'accordion-down': {
80 | from: {
81 | height: '0'
82 | },
83 | to: {
84 | height: 'var(--radix-accordion-content-height)'
85 | }
86 | },
87 | 'accordion-up': {
88 | from: {
89 | height: 'var(--radix-accordion-content-height)'
90 | },
91 | to: {
92 | height: '0'
93 | }
94 | },
95 | 'fade-in': {
96 | '0%': { opacity: '0' },
97 | '100%': { opacity: '1' },
98 | },
99 | 'fade-out': {
100 | '0%': { opacity: '1' },
101 | '100%': { opacity: '0' },
102 | },
103 | 'slide-up': {
104 | '0%': { transform: 'translateY(10px)', opacity: '0' },
105 | '100%': { transform: 'translateY(0)', opacity: '1' },
106 | },
107 | },
108 | animation: {
109 | 'accordion-down': 'accordion-down 0.2s ease-out',
110 | 'accordion-up': 'accordion-up 0.2s ease-out',
111 | 'fade-in': 'fade-in 0.3s ease-out',
112 | 'fade-out': 'fade-out 0.3s ease-out',
113 | 'slide-up': 'slide-up 0.4s ease-out',
114 | }
115 | }
116 | },
117 | plugins: [
118 | require("tailwindcss-animate"),
119 | require('@tailwindcss/typography'),
120 | ],
121 | };
122 | export default config;
123 |
--------------------------------------------------------------------------------
/app/blog/page.tsx:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import matter from 'gray-matter';
4 | import Link from 'next/link';
5 |
6 | interface BlogPost {
7 | slug: string;
8 | title: string;
9 | date: string;
10 | description: string;
11 | tags: string[];
12 | }
13 |
14 | export async function generateMetadata() {
15 | return {
16 | title: '博客文章 | 独立开发者出海工具箱',
17 | description: '独立开发者出海工具箱的博客文章,分享出海经验、工具使用和成功案例。',
18 | };
19 | }
20 |
21 | function getBlogPosts(): BlogPost[] {
22 | const postsDirectory = path.join(process.cwd(), 'content/blog');
23 | const fileNames = fs.readdirSync(postsDirectory);
24 |
25 | const posts = fileNames
26 | .filter((fileName) => fileName.endsWith('.md'))
27 | .map((fileName) => {
28 | const slug = fileName.replace(/\.md$/, '');
29 | const fullPath = path.join(postsDirectory, fileName);
30 | const fileContents = fs.readFileSync(fullPath, 'utf8');
31 | const { data } = matter(fileContents);
32 |
33 | return {
34 | slug,
35 | title: data.title,
36 | date: data.date,
37 | description: data.description,
38 | tags: data.tags || [],
39 | };
40 | })
41 | .sort((a, b) => (new Date(b.date) as any) - (new Date(a.date) as any));
42 |
43 | return posts;
44 | }
45 |
46 | export default function BlogPage() {
47 | const posts = getBlogPosts();
48 |
49 | return (
50 |
51 |
52 |
53 |
57 |
71 | 返回首页
72 |
73 |
74 |
75 | 博客文章
76 |
77 |
78 |
79 | {posts.map((post) => (
80 |
84 |
85 |
86 | {post.title}
87 |
88 |
89 |
90 |
97 |
98 |
99 | {post.description}
100 |
101 |
102 | {post.tags.map((tag: string) => (
103 |
107 | {tag}
108 |
109 | ))}
110 |
111 |
112 | ))}
113 |
114 |
115 |
116 | );
117 | }
--------------------------------------------------------------------------------
/app/blog/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import matter from 'gray-matter';
4 | import { unified } from 'unified';
5 | import remarkParse from 'remark-parse';
6 | import remarkRehype from 'remark-rehype';
7 | import rehypeExternalLinks from 'rehype-external-links';
8 | import rehypeStringify from 'rehype-stringify';
9 | import { Metadata } from 'next';
10 | import Link from 'next/link';
11 |
12 | interface BlogPostProps {
13 | params: {
14 | slug: string;
15 | };
16 | }
17 |
18 | export async function generateStaticParams() {
19 | const postsDirectory = path.join(process.cwd(), 'content/blog');
20 | const fileNames = fs.readdirSync(postsDirectory);
21 |
22 | return fileNames
23 | .filter((fileName) => fileName.endsWith('.md'))
24 | .map((fileName) => ({
25 | slug: fileName.replace(/\.md$/, ''),
26 | }));
27 | }
28 |
29 | export async function generateMetadata({ params }: BlogPostProps): Promise {
30 | const post = await getPost(params.slug);
31 |
32 | return {
33 | title: `${post.title} | 独立开发者出海工具箱`,
34 | description: post.description,
35 | openGraph: {
36 | title: post.title,
37 | description: post.description,
38 | type: 'article',
39 | publishedTime: post.date,
40 | tags: post.tags,
41 | },
42 | };
43 | }
44 |
45 | async function getPost(slug: string) {
46 | const fullPath = path.join(process.cwd(), 'content/blog', `${slug}.md`);
47 | const fileContents = fs.readFileSync(fullPath, 'utf8');
48 | const { data, content } = matter(fileContents);
49 |
50 | const processedContent = await unified()
51 | .use(remarkParse)
52 | .use(remarkRehype)
53 | .use(rehypeExternalLinks, {
54 | target: '_blank',
55 | rel: ['noopener', 'noreferrer']
56 | })
57 | .use(rehypeStringify)
58 | .process(content);
59 |
60 | const contentHtml = processedContent.toString();
61 |
62 | return {
63 | slug,
64 | contentHtml,
65 | title: data.title,
66 | date: data.date,
67 | description: data.description,
68 | tags: data.tags || [],
69 | };
70 | }
71 |
72 | export default async function BlogPost({ params }: BlogPostProps) {
73 | const post = await getPost(params.slug);
74 |
75 | return (
76 |
77 |
78 |
79 |
83 |
97 | 返回博客列表
98 |
99 |
100 |
101 |
102 | {post.title}
103 |
104 |
105 |
112 |
113 |
114 | {post.tags.map((tag: string) => (
115 |
119 | {tag}
120 |
121 | ))}
122 |
123 |
124 |
125 |
129 |
130 |
131 | );
132 | }
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 250, 250, 252;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 10, 10, 15;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | @layer utilities {
20 | .text-balance {
21 | text-wrap: balance;
22 | }
23 | }
24 |
25 | @layer base {
26 | :root {
27 | --background: 220 33% 98%;
28 | --foreground: 240 10% 3.9%;
29 | --card: 0 0% 100%;
30 | --card-foreground: 240 10% 3.9%;
31 | --popover: 0 0% 100%;
32 | --popover-foreground: 240 10% 3.9%;
33 | --primary: 262 83% 58%;
34 | --primary-foreground: 0 0% 98%;
35 | --secondary: 240 4.8% 95.9%;
36 | --secondary-foreground: 240 5.9% 10%;
37 | --muted: 240 4.8% 95.9%;
38 | --muted-foreground: 240 3.8% 46.1%;
39 | --accent: 262 83% 58%;
40 | --accent-foreground: 0 0% 98%;
41 | --destructive: 0 84.2% 60.2%;
42 | --destructive-foreground: 0 0% 98%;
43 | --border: 240 5.9% 90%;
44 | --input: 240 5.9% 90%;
45 | --ring: 262 83% 58%;
46 | --chart-1: 262 83% 58%;
47 | --chart-2: 220 70% 50%;
48 | --chart-3: 197 37% 24%;
49 | --chart-4: 43 74% 66%;
50 | --chart-5: 27 87% 67%;
51 | --radius: 0.5rem;
52 | }
53 | .dark {
54 | --background: 240 10% 3.9%;
55 | --foreground: 0 0% 98%;
56 | --card: 240 10% 3.9%;
57 | --card-foreground: 0 0% 98%;
58 | --popover: 240 10% 3.9%;
59 | --popover-foreground: 0 0% 98%;
60 | --primary: 262 83% 58%;
61 | --primary-foreground: 0 0% 98%;
62 | --secondary: 240 3.7% 15.9%;
63 | --secondary-foreground: 0 0% 98%;
64 | --muted: 240 3.7% 15.9%;
65 | --muted-foreground: 240 5% 64.9%;
66 | --accent: 262 83% 58%;
67 | --accent-foreground: 0 0% 98%;
68 | --destructive: 0 62.8% 30.6%;
69 | --destructive-foreground: 0 0% 98%;
70 | --border: 240 3.7% 15.9%;
71 | --input: 240 3.7% 15.9%;
72 | --ring: 262 83% 58%;
73 | --chart-1: 262 83% 58%;
74 | --chart-2: 220 70% 50%;
75 | --chart-3: 30 80% 55%;
76 | --chart-4: 280 65% 60%;
77 | --chart-5: 340 75% 55%;
78 | }
79 | }
80 |
81 | @layer base {
82 | * {
83 | @apply border-border;
84 | }
85 | body {
86 | @apply bg-background text-foreground;
87 | background-image: radial-gradient(
88 | circle at top center,
89 | rgba(var(--background-start-rgb), 0.3),
90 | rgba(var(--background-end-rgb), 0)
91 | );
92 | font-feature-settings: "rlig" 1, "calt" 1;
93 | }
94 | h1, h2, h3, h4, h5, h6 {
95 | @apply font-medium tracking-tight;
96 | }
97 | h1 {
98 | @apply text-4xl md:text-5xl;
99 | }
100 | h2 {
101 | @apply text-3xl md:text-4xl;
102 | }
103 | h3 {
104 | @apply text-2xl md:text-3xl;
105 | }
106 | }
107 |
108 | /* Custom styles for Directory Website */
109 | @layer components {
110 | .directory-item {
111 | @apply border border-border/60 rounded-xl p-5 hover:shadow-lg transition-all duration-300 bg-white dark:bg-card;
112 | }
113 |
114 | .directory-tag {
115 | @apply text-sm text-muted-foreground bg-secondary px-3 py-1 rounded-full font-medium;
116 | }
117 |
118 | .directory-tech {
119 | @apply text-xs text-primary font-medium;
120 | }
121 |
122 | .directory-icon {
123 | @apply w-10 h-10 rounded-lg flex items-center justify-center text-white text-xs shadow-sm;
124 | }
125 |
126 | .directory-icon-dir {
127 | @apply bg-primary;
128 | }
129 |
130 | .directory-icon-app {
131 | @apply bg-accent;
132 | }
133 |
134 | .btn {
135 | @apply px-4 py-2 rounded-full font-medium transition-all duration-200 inline-flex items-center justify-center;
136 | }
137 |
138 | .btn-primary {
139 | @apply bg-primary text-primary-foreground hover:bg-primary/90;
140 | }
141 |
142 | .btn-secondary {
143 | @apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
144 | }
145 |
146 | .btn-outline {
147 | @apply border border-input bg-background hover:bg-accent hover:text-accent-foreground;
148 | }
149 |
150 | .input-search {
151 | @apply w-full p-3 pr-12 border border-input rounded-full focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200;
152 | }
153 |
154 | .nav-link {
155 | @apply text-muted-foreground hover:text-foreground transition-colors duration-200;
156 | }
157 |
158 | .nav-link-active {
159 | @apply text-primary font-medium;
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/components/common/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState, useEffect } from 'react';
4 | import { Moon, Sun, FileText, GitPullRequest, BookOpen, Menu, X } from 'lucide-react';
5 | import Link from 'next/link';
6 |
7 | interface HeaderProps {
8 | onDeployBtnClick?: () => void;
9 | }
10 |
11 | export const Header: React.FC = ({ onDeployBtnClick }) => {
12 | const [isScrolled, setIsScrolled] = useState(false);
13 | const [isDarkMode, setIsDarkMode] = useState(false);
14 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
15 |
16 | useEffect(() => {
17 | const handleScroll = () => {
18 | setIsScrolled(window.scrollY > 10);
19 | };
20 |
21 | window.addEventListener('scroll', handleScroll);
22 |
23 | // Check system preference for dark mode
24 | if (typeof window !== 'undefined') {
25 | setIsDarkMode(document.documentElement.classList.contains('dark'));
26 | }
27 |
28 | return () => window.removeEventListener('scroll', handleScroll);
29 | }, []);
30 |
31 | const toggleDarkMode = () => {
32 | document.documentElement.classList.toggle('dark');
33 | setIsDarkMode(!isDarkMode);
34 | };
35 |
36 | return (
37 |
169 | );
170 | };
--------------------------------------------------------------------------------
/components/Base.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState, useEffect } from 'react';
4 | import { DirectoryItemProps } from '@/lib/directory-data';
5 | import { fuzzySearch, extractDomain } from '@/lib/search-utils';
6 | import { Header } from './common/Header';
7 | import { DirectoryItem } from './common/DirectoryItem';
8 | import { SearchBar } from './common/SearchBar';
9 | import { FilterSection } from './common/FilterSection';
10 | import { ArrowRight, Info, X } from 'lucide-react';
11 |
12 | interface BaseProps {
13 | initialDirectoryItems: DirectoryItemProps[];
14 | initialTags: string[];
15 | }
16 |
17 | export const Base = ({ initialDirectoryItems, initialTags }: BaseProps) => {
18 | const [directoryItems, setDirectoryItems] = useState(
19 | initialDirectoryItems
20 | );
21 | const [allDirectoryItems] = useState(
22 | initialDirectoryItems
23 | );
24 | const [allTags] = useState(initialTags);
25 | const [searchQuery, setSearchQuery] = useState('');
26 | const [isLoading, setIsLoading] = useState(false);
27 | const [error, setError] = useState(null);
28 | const [selectedTag, setSelectedTag] = useState('');
29 | const [isHydrated, setIsHydrated] = useState(false);
30 | const [initialLoading, setInitialLoading] = useState(true);
31 | const [isSiteEnv, setIsSiteEnv] = useState(false);
32 |
33 | // Mark component as hydrated after initial render
34 | useEffect(() => {
35 | setIsHydrated(true);
36 | // Check if we're in site environment
37 | setIsSiteEnv(window.location.href.includes('.site'));
38 | // Set a small timeout to ensure smooth transition
39 | const timer = setTimeout(() => {
40 | setInitialLoading(false);
41 | }, 100);
42 | return () => clearTimeout(timer);
43 | }, []);
44 |
45 | useEffect(() => {
46 | if (isHydrated) {
47 | localStorage.setItem('raindropTags', JSON.stringify(allTags));
48 | }
49 | }, [allTags, isHydrated]);
50 |
51 | // Helper function to open URLs based on environment
52 | const openUrl = (siteEnvUrl: string, defaultUrl: string) => {
53 | const url = isSiteEnv ? siteEnvUrl : defaultUrl;
54 | window.open(url, '_blank');
55 | };
56 |
57 | // Handle deploy button click
58 | const onDeployBtnClick = () => {
59 | openUrl(
60 | 'https://console.cloud.tencent.com/edgeone/pages/new?from=github&template=directory',
61 | 'https://edgeone.ai/pages/templates/directory'
62 | );
63 | };
64 |
65 | // Group directory items by tags
66 | const groupItemsByTag = (items: DirectoryItemProps[]) => {
67 | const groupedItems: Record = {};
68 |
69 | // Initialize groups for all tags
70 | allTags.forEach(tag => {
71 | groupedItems[tag] = [];
72 | });
73 |
74 | // Add items to their respective tag groups
75 | // For items with multiple tags, use the first tag as the primary group
76 | items.forEach(item => {
77 | if (item.tags.length > 0) {
78 | const primaryTag = item.tags[0];
79 | if (groupedItems[primaryTag]) {
80 | groupedItems[primaryTag].push(item);
81 | }
82 | }
83 | });
84 |
85 | // Filter out empty groups
86 | return Object.entries(groupedItems)
87 | .filter(([_, items]) => items.length > 0)
88 | .sort(([tagA], [tagB]) => tagA.localeCompare(tagB));
89 | };
90 |
91 | const handleSearch = (query: string) => {
92 | setSearchQuery(query);
93 | setIsLoading(true);
94 |
95 | setTimeout(() => {
96 | if (!query) {
97 | setDirectoryItems(allDirectoryItems);
98 | setIsLoading(false);
99 | return;
100 | }
101 |
102 | // Use fuzzy search to find and rank matches
103 | const searchResults = allDirectoryItems.map(item => {
104 | // Calculate match scores for different fields
105 | const titleScore = fuzzySearch(item.title, query);
106 |
107 | // Get the best tag score
108 | const tagScore = Math.max(
109 | ...item.tags.map(tag => fuzzySearch(tag, query)),
110 | 0 // Default if no tags
111 | );
112 |
113 | // Content scores
114 | const excerptScore = item.excerpt ? fuzzySearch(item.excerpt, query) : 0;
115 | const noteScore = item.note ? fuzzySearch(item.note, query) : 0;
116 |
117 | // Domain score
118 | const domain = item.link ? extractDomain(item.link) : '';
119 | const domainScore = fuzzySearch(domain, query);
120 |
121 | // Calculate final score - prioritize title and domain matches
122 | const maxScore = Math.max(
123 | titleScore * 1.2, // Title matches are most important
124 | tagScore * 1.1, // Tag matches are also important
125 | excerptScore,
126 | noteScore,
127 | domainScore * 1.1 // Domain matches are important too
128 | );
129 |
130 | return {
131 | item,
132 | score: maxScore
133 | };
134 | });
135 |
136 | // Filter items with a score above the threshold and sort by score
137 | const threshold = 0.2; // Minimum score to be considered a match
138 | const filtered = searchResults
139 | .filter(result => result.score > threshold)
140 | .sort((a, b) => b.score - a.score) // Sort by score descending
141 | .map(result => result.item);
142 |
143 | setDirectoryItems(filtered);
144 | setIsLoading(false);
145 | }, 400);
146 | };
147 |
148 | const handleFilterChange = (filters: { tag?: string }) => {
149 | setIsLoading(true);
150 | setSelectedTag(filters.tag || '');
151 |
152 | setTimeout(() => {
153 | let filtered = [...allDirectoryItems];
154 |
155 | if (searchQuery) {
156 | // Use the same fuzzy search logic
157 | const searchResults = filtered.map(item => {
158 | // Calculate match scores for different fields
159 | const titleScore = fuzzySearch(item.title, searchQuery);
160 |
161 | // Get the best tag score
162 | const tagScore = Math.max(
163 | ...item.tags.map(tag => fuzzySearch(tag, searchQuery)),
164 | 0 // Default if no tags
165 | );
166 |
167 | // Content scores
168 | const excerptScore = item.excerpt ? fuzzySearch(item.excerpt, searchQuery) : 0;
169 | const noteScore = item.note ? fuzzySearch(item.note, searchQuery) : 0;
170 |
171 | // Domain score
172 | const domain = item.link ? extractDomain(item.link) : '';
173 | const domainScore = fuzzySearch(domain, searchQuery);
174 |
175 | // Calculate final score - prioritize title and domain matches
176 | const maxScore = Math.max(
177 | titleScore * 1.2, // Title matches are most important
178 | tagScore * 1.1, // Tag matches are also important
179 | excerptScore,
180 | noteScore,
181 | domainScore * 1.1 // Domain matches are important too
182 | );
183 |
184 | return {
185 | item,
186 | score: maxScore
187 | };
188 | });
189 |
190 | // Filter items with a score above the threshold and sort by score
191 | const threshold = 0.2; // Minimum score to be considered a match
192 | filtered = searchResults
193 | .filter(result => result.score > threshold)
194 | .sort((a, b) => b.score - a.score) // Sort by score descending
195 | .map(result => result.item);
196 | }
197 |
198 | if (filters.tag) {
199 | filtered = filtered.filter((item) =>
200 | item.tags.some((tag) => tag === filters.tag)
201 | );
202 | }
203 |
204 | setDirectoryItems(filtered);
205 | setIsLoading(false);
206 | }, 300);
207 | };
208 |
209 | const handleReset = () => {
210 | setSearchQuery('');
211 | setIsLoading(true);
212 | setSelectedTag('');
213 |
214 | setTimeout(() => {
215 | setDirectoryItems(allDirectoryItems);
216 | setIsLoading(false);
217 | }, 300);
218 | };
219 |
220 | // Skeleton loader component
221 | const SkeletonLoader = () => (
222 |
223 | {[...Array(6)].map((_, index) => (
224 |
225 |
229 |
233 |
234 |
238 |
239 | ))}
240 |
241 | );
242 |
243 | // Add CSS for shimmer effect
244 | useEffect(() => {
245 | if (typeof document !== 'undefined') {
246 | const style = document.createElement('style');
247 | style.innerHTML = `
248 | @keyframes shimmer {
249 | 0% {
250 | background-position: -200% 0;
251 | }
252 | 100% {
253 | background-position: 200% 0;
254 | }
255 | }
256 | .shimmer {
257 | background: linear-gradient(90deg,
258 | rgba(0,0,0,0.06) 25%,
259 | rgba(0,0,0,0.1) 37%,
260 | rgba(0,0,0,0.06) 63%
261 | );
262 | background-size: 200% 100%;
263 | animation: shimmer 1.5s infinite;
264 | animation-timing-function: ease-in-out;
265 | }
266 | .dark .shimmer {
267 | background: linear-gradient(90deg,
268 | rgba(255,255,255,0.06) 25%,
269 | rgba(255,255,255,0.1) 37%,
270 | rgba(255,255,255,0.06) 63%
271 | );
272 | background-size: 200% 100%;
273 | animation: shimmer 1.5s infinite;
274 | animation-timing-function: ease-in-out;
275 | }
276 | `;
277 | document.head.appendChild(style);
278 |
279 | return () => {
280 | document.head.removeChild(style);
281 | };
282 | }
283 | }, []);
284 |
285 | return (
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 | 独立开发者
294 | 出海工具箱
295 |
296 |
297 | {'灵感、设计、开发、增长、变现、工具、资源'}
298 |
299 |
300 |
301 |
302 |
303 | {isHydrated && (
304 |
310 | )}
311 |
312 |
313 | {isLoading || initialLoading ? (
314 |
315 | ) : directoryItems.length > 0 ? (
316 | selectedTag === '' ? (
317 | // Display items grouped by tags when no tag is selected
318 |
319 | {groupItemsByTag(directoryItems).map(([tag, items]) => (
320 |
321 |
322 |
323 |
324 | {tag}
325 |
326 |
327 |
328 |
329 |
330 | {items.map((item) => (
331 |
332 | ))}
333 |
334 |
335 | ))}
336 |
337 | ) : (
338 | // Display items in a regular grid when a tag is selected
339 |
340 | {directoryItems.map((item) => (
341 |
342 | ))}
343 |
344 | )
345 | ) : (
346 |
347 |
348 | 没有找到符合条件的内容。
349 |
350 |
356 |
357 | )}
358 |
359 |
360 |
361 |
362 | 准备创建您自己的导航站?
363 |
364 |
365 | 使用我们的 EdgeOne Pages
366 | 模板,几分钟内即可构建美观、响应式的导航网站。
367 |
368 |
373 |
374 |
375 |
376 |
377 | {/* Footer */}
378 |
389 |
390 | );
391 | };
392 |
--------------------------------------------------------------------------------