├── .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 | [![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](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 | {`${title} 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 | [![Deploy with EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](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 | 28 | 35 | 42 | 49 | 50 | 51 | 导航站模板 52 | 53 | 54 | 55 | 59 | 返回首页 60 | 61 |
62 |
63 | 64 |
65 |
66 |
67 |
68 | 74 |
75 |
76 |
77 |
78 | 79 |
80 |
81 |

使用 Raindrop.io 作为无头CMS构建 | Indie Hacker Tools

82 |
83 |
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 | 64 | 70 | 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 | 90 | 96 | 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 |
40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | 导航站模板 48 |
49 | 50 | {/* Desktop Navigation */} 51 |
52 | 57 | 58 | 博客 59 | 60 | 61 | 66 | 67 | 文档 68 | 69 | 70 | 77 | 78 | 投稿 79 | 80 | 81 | 91 | 92 | 99 |
100 | 101 | {/* Mobile Menu Button */} 102 |
103 | 113 | 120 |
121 |
122 | 123 | {/* Mobile Navigation Menu */} 124 | {isMobileMenuOpen && ( 125 |
126 |
127 | setIsMobileMenuOpen(false)} 131 | > 132 | 133 | 博客 134 | 135 | 136 | setIsMobileMenuOpen(false)} 140 | > 141 | 142 | 文档 143 | 144 | 145 | setIsMobileMenuOpen(false)} 151 | > 152 | 153 | 投稿 154 | 155 | 156 | 165 |
166 |
167 | )} 168 |
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 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
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 | --------------------------------------------------------------------------------