├── .gitignore ├── README.md ├── eslint.config.mjs ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── fonts │ ├── README.md │ ├── qweather-icons.ttf │ ├── qweather-icons.woff │ └── qweather-icons.woff2 ├── globe.svg └── logo_jike.png ├── src ├── app │ ├── api │ │ ├── config │ │ │ └── route.ts │ │ ├── hot-news │ │ │ └── route.ts │ │ ├── links │ │ │ └── route.ts │ │ └── weather │ │ │ ├── air │ │ │ └── route.ts │ │ │ ├── geo │ │ │ └── route.ts │ │ │ ├── ip │ │ │ └── route.ts │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── analytics │ │ ├── Clarity.tsx │ │ ├── GoogleAnalytics.tsx │ │ └── index.ts │ ├── layout │ │ ├── AnimatedMain.tsx │ │ ├── Footer.tsx │ │ ├── LinkContainer.tsx │ │ ├── Navigation.tsx │ │ └── WidgetsContainer.tsx │ ├── ui │ │ ├── LinkCard.tsx │ │ ├── ThemeProvider.tsx │ │ ├── ThemeSwitcher.tsx │ │ └── index.ts │ └── widgets │ │ ├── AnalogClock.tsx │ │ ├── HotNews.tsx │ │ ├── IPInfo.tsx │ │ ├── SimpleTime.tsx │ │ ├── Weather.tsx │ │ └── index.ts ├── config │ └── index.ts ├── lib │ ├── category.ts │ ├── notion.ts │ ├── themes.ts │ └── utils.ts ├── themes │ ├── README.md │ ├── cyberpunk │ │ ├── index.ts │ │ └── style.css │ ├── index.ts │ ├── simple │ │ ├── base.css │ │ ├── index.ts │ │ └── style.css │ └── theme.css └── types │ ├── lunar-javascript.d.ts │ ├── notion.ts │ └── theme.ts ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.* 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/versions 10 | 11 | # Testing 12 | /coverage 13 | .nyc_output 14 | 15 | # Next.js 16 | /.next/ 17 | /out/ 18 | .next/ 19 | next-env.d.ts 20 | 21 | # Production 22 | /build 23 | .output 24 | /dist 25 | 26 | # IDE and Editor files 27 | .idea/ 28 | .vscode/ 29 | *.swp 30 | *.swo 31 | .DS_Store 32 | *.pem 33 | 34 | # Debug logs 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | .pnpm-debug.log* 39 | 40 | # Local env files 41 | .env 42 | .env*.local 43 | .env.development 44 | .env.test 45 | .env.production 46 | 47 | # Vercel 48 | .vercel 49 | 50 | # TypeScript 51 | *.tsbuildinfo 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion 导航站 2 | 3 | 4 | ## 更新说明 5 | 🎉更新内容及更新方法见[保姆级教程](https://ezho.top/code/2025/02/21/notion-bookmarks-handbook) 6 | 7 |
8 | 2025/5/19 9 | - 2025/5/19 新增小组件功能,简易时钟/天气/圆形时钟/IP信息/热搜
10 | demo 11 | demo 12 |
13 | 14 |
15 | 2025/3/7 16 | - 2025/3/7 新增主题配置,新增赛博朋克主题
17 | demo 18 |
19 | 20 | ## 项目预览 21 | > 🔗 [在线演示](https://portal.ezho.top/) 22 | ![项目预览](https://github.com/user-attachments/assets/1d864d20-44b3-4678-b649-6ba96821f1c4) 23 | 24 | 25 | 26 | ## 项目简介 27 | 这是一个使用 Notion 作为数据库后端的个人导航网站项目。通过 Notion 数据库管理书签和导航链接,并以清晰现代的网页界面呈现。 28 | 29 | ### 主要特性 30 | - 使用 Notion 作为数据库,无需部署数据库 31 | - 清晰现代的网页界面 32 | - 支持多级分类导航 33 | - 响应式设计,支持桌面和移动端 34 | - 支持多主题切换(简约主题、赛博朋克主题) 35 | - 一键部署到 Vercel 36 | 37 | ## 快速开始 38 | [保姆级教程](https://ezho.top/code/2025/02/21/notion-bookmarks-handbook) 39 | 40 | 41 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const config: NextConfig = { 4 | images: { 5 | // 禁用图片优化以避免付费服务 6 | unoptimized: true, 7 | }, 8 | eslint: { 9 | ignoreDuringBuilds: true, 10 | }, 11 | // 优化资源加载 12 | experimental: { 13 | optimizeCss: true, 14 | }, 15 | // 优化预加载 16 | onDemandEntries: { 17 | maxInactiveAge: 25 * 1000, 18 | pagesBufferLength: 2, 19 | }, 20 | }; 21 | 22 | export default config; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-bookmarks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^2.2.0", 13 | "@notionhq/client": "^2.2.15", 14 | "@radix-ui/react-icons": "^1.3.2", 15 | "@radix-ui/react-slot": "^1.1.1", 16 | "@tabler/icons-react": "^3.26.0", 17 | "@vercel/kv": "^3.0.0", 18 | "axios": "^1.8.3", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.1", 21 | "critters": "^0.0.25", 22 | "framer-motion": "^11.15.0", 23 | "iconv-lite": "^0.6.3", 24 | "lucide-react": "^0.468.0", 25 | "lunar-javascript": "^1.7.1", 26 | "next": "15.1.0", 27 | "next-themes": "^0.4.4", 28 | "node-html-parser": "^7.0.1", 29 | "qweather-icons": "^1.6.0", 30 | "react": "^19.0.0", 31 | "react-dom": "^19.0.0", 32 | "react-icons": "^5.4.0", 33 | "tailwind-merge": "^2.5.5", 34 | "zustand": "^5.0.3" 35 | }, 36 | "devDependencies": { 37 | "@eslint/eslintrc": "^3", 38 | "@types/node": "^20.17.10", 39 | "@types/react": "^19", 40 | "@types/react-dom": "^19", 41 | "eslint": "^9", 42 | "eslint-config-next": "15.1.0", 43 | "postcss": "^8", 44 | "tailwind-scrollbar": "^4.0.2", 45 | "tailwindcss": "^3.4.1", 46 | "ts-node": "^10.9.2", 47 | "typescript": "^5.7.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/fonts/README.md: -------------------------------------------------------------------------------- 1 | # Weather Icons Fonts 2 | 3 | 这个目录包含从qweather-icons包复制过来的字体文件,用于天气组件显示天气图标。 4 | 5 | 原始文件位置:`/node_modules/qweather-icons/font/fonts/` -------------------------------------------------------------------------------- /public/fonts/qweather-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moyuguy/notion_bookmarks/23e3f95ab74dc5e2be4a9021744ce79c8d87379b/public/fonts/qweather-icons.ttf -------------------------------------------------------------------------------- /public/fonts/qweather-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moyuguy/notion_bookmarks/23e3f95ab74dc5e2be4a9021744ce79c8d87379b/public/fonts/qweather-icons.woff -------------------------------------------------------------------------------- /public/fonts/qweather-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moyuguy/notion_bookmarks/23e3f95ab74dc5e2be4a9021744ce79c8d87379b/public/fonts/qweather-icons.woff2 -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/logo_jike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moyuguy/notion_bookmarks/23e3f95ab74dc5e2be4a9021744ce79c8d87379b/public/logo_jike.png -------------------------------------------------------------------------------- /src/app/api/config/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/config/route.ts 2 | import { getWebsiteConfig } from '@/lib/notion'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | export async function GET() { 6 | try { 7 | console.error('API: 开始获取网站配置...'); 8 | const config = await getWebsiteConfig(); 9 | console.error('API: 成功获取配置:', config); 10 | return NextResponse.json(config); 11 | } catch (error) { 12 | console.error('获取配置失败:', error); 13 | return NextResponse.json( 14 | { error: '获取配置失败' }, 15 | { status: 500 } 16 | ); 17 | } 18 | } -------------------------------------------------------------------------------- /src/app/api/hot-news/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { parse } from 'node-html-parser'; 3 | 4 | interface HotNewsItem { 5 | title: string; 6 | url: string; 7 | views: string; 8 | platform: string; 9 | } 10 | 11 | // 内存缓存 12 | let cache: { 13 | data: any; 14 | timestamp: number; 15 | } | null = null; 16 | 17 | const CACHE_TIME = 15 * 60 * 1000; // 15分钟 18 | 19 | // 获取微博热搜 20 | async function getWeiboHotNews(): Promise { 21 | try { 22 | const response = await fetch('https://weibo.com/ajax/side/hotSearch'); 23 | const data = await response.json(); 24 | 25 | return data.data.realtime 26 | .slice(0, 5) 27 | .map((item: any) => ({ 28 | title: item.note, 29 | url: `https://s.weibo.com/weibo?q=${encodeURIComponent(item.note)}`, 30 | views: `${(item.num / 10000).toFixed(1)}万`, 31 | platform: 'weibo' 32 | })); 33 | } catch (error) { 34 | console.error('Failed to fetch Weibo hot news:', error); 35 | return []; 36 | } 37 | } 38 | 39 | // 获取百度热搜 40 | async function getBaiduHotNews(): Promise { 41 | try { 42 | const response = await fetch('https://api.iyk0.com/baiduhot'); 43 | const data = await response.json(); 44 | 45 | if (!data.data) { 46 | // 如果第一个API失败,尝试备用API 47 | const backupResponse = await fetch('https://api.vvhan.com/api/hotlist?type=baidu'); 48 | const backupData = await backupResponse.json(); 49 | 50 | return backupData.data 51 | .slice(0, 5) 52 | .map((item: any) => ({ 53 | title: item.title, 54 | url: item.url, 55 | views: item.hot || '热搜', 56 | platform: 'baidu' 57 | })); 58 | } 59 | 60 | return data.data 61 | .slice(0, 5) 62 | .map((item: any) => ({ 63 | title: item.title, 64 | url: item.url, 65 | views: item.hot_score || '热搜', 66 | platform: 'baidu' 67 | })); 68 | } catch (error) { 69 | console.error('Failed to fetch Baidu hot news:', error); 70 | // 如果上面都失败了,尝试直接抓取百度页面 71 | try { 72 | const response = await fetch('https://top.baidu.com/board?tab=realtime', { 73 | headers: { 74 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 75 | } 76 | }); 77 | const html = await response.text(); 78 | const root = parse(html); 79 | 80 | return root.querySelectorAll('.content_1YWBm') 81 | .slice(0, 5) 82 | .map(item => { 83 | const title = item.querySelector('.c-single-text-ellipsis')?.text?.trim(); 84 | const url = item.querySelector('a')?.getAttribute('href'); 85 | const hot = item.querySelector('.hot-index_1Bl1a')?.text; 86 | 87 | return { 88 | title: title || '', 89 | url: url || `https://www.baidu.com/s?wd=${encodeURIComponent(title || '')}`, 90 | views: hot || '热搜', 91 | platform: 'baidu' 92 | }; 93 | }); 94 | } catch (backupError) { 95 | console.error('Failed to fetch Baidu hot news from backup:', backupError); 96 | return []; 97 | } 98 | } 99 | } 100 | 101 | // 获取B站热搜 102 | async function getBilibiliHotNews(): Promise { 103 | try { 104 | const response = await fetch('https://api.bilibili.com/x/web-interface/search/square?limit=10'); 105 | const data = await response.json(); 106 | 107 | return data.data.trending.list 108 | .slice(0, 5) 109 | .map((item: any) => ({ 110 | title: item.keyword, 111 | url: `https://search.bilibili.com/all?keyword=${encodeURIComponent(item.keyword)}`, 112 | views: `${item.hot || '热'}`, 113 | platform: 'bilibili' 114 | })); 115 | } catch (error) { 116 | console.error('Failed to fetch Bilibili hot news:', error); 117 | // 备用API 118 | try { 119 | const backupResponse = await fetch('https://api.vvhan.com/api/hotlist?type=bili'); 120 | const backupData = await backupResponse.json(); 121 | 122 | return backupData.data 123 | .slice(0, 5) 124 | .map((item: any) => ({ 125 | title: item.title, 126 | url: item.url || `https://search.bilibili.com/all?keyword=${encodeURIComponent(item.title)}`, 127 | views: `${item.hot || '热'}`, 128 | platform: 'bilibili' 129 | })); 130 | } catch (backupError) { 131 | console.error('Failed to fetch Bilibili hot news from backup:', backupError); 132 | return []; 133 | } 134 | } 135 | } 136 | 137 | // 获取头条热搜 138 | async function getToutiaoHotNews(): Promise { 139 | try { 140 | const response = await fetch('https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc'); 141 | const data = await response.json(); 142 | 143 | return data.data 144 | .slice(0, 5) 145 | .map((item: any) => ({ 146 | title: item.Title, 147 | url: `https://www.toutiao.com/search/?keyword=${encodeURIComponent(item.Title)}`, 148 | views: `${(item.HotValue / 10000).toFixed(1)}万`, 149 | platform: 'toutiao' 150 | })); 151 | } catch (error) { 152 | console.error('Failed to fetch Toutiao hot news:', error); 153 | return []; 154 | } 155 | } 156 | 157 | // 获取抖音热搜 158 | async function getDouyinHotNews(): Promise { 159 | try { 160 | const response = await fetch('https://aweme.snssdk.com/aweme/v1/hot/search/list/'); 161 | const data = await response.json(); 162 | 163 | if (data.status_code === 0) { 164 | return data.data.word_list 165 | .slice(0, 5) 166 | .map((item: any) => ({ 167 | title: item.word, 168 | url: `https://www.douyin.com/hot/${encodeURIComponent(item.sentence_id)}`, 169 | views: `${(Number(item.hot_value) / 10000).toFixed(1)}万`, 170 | platform: 'douyin' 171 | })); 172 | } 173 | return []; 174 | } catch (error) { 175 | console.error('Failed to fetch Douyin hot news:', error); 176 | // 如果官方API失败,尝试备用API 177 | try { 178 | const backupResponse = await fetch('https://api.vvhan.com/api/hotlist?type=douyin'); 179 | const backupData = await backupResponse.json(); 180 | 181 | return backupData.data 182 | .slice(0, 5) 183 | .map((item: any) => ({ 184 | title: item.title, 185 | url: `https://www.douyin.com/search/${encodeURIComponent(item.title)}`, 186 | views: item.hot || '热搜', 187 | platform: 'douyin' 188 | })); 189 | } catch (backupError) { 190 | console.error('Failed to fetch Douyin hot news from backup:', backupError); 191 | return []; 192 | } 193 | } 194 | } 195 | 196 | // 获取所有平台的热搜 197 | async function getAllHotNews() { 198 | const [weiboNews, baiduNews, bilibiliNews, toutiaoNews, douyinNews] = await Promise.all([ 199 | getWeiboHotNews(), 200 | getBaiduHotNews(), 201 | getBilibiliHotNews(), 202 | getToutiaoHotNews(), 203 | getDouyinHotNews() 204 | ]); 205 | 206 | return { 207 | weibo: weiboNews, 208 | baidu: baiduNews, 209 | bilibili: bilibiliNews, 210 | toutiao: toutiaoNews, 211 | douyin: douyinNews 212 | }; 213 | } 214 | 215 | // 获取缓存的热搜数据 216 | async function getCachedHotNews() { 217 | const now = Date.now(); 218 | 219 | // 如果缓存存在且未过期,直接返回缓存数据 220 | if (cache && now - cache.timestamp < CACHE_TIME) { 221 | return cache.data; 222 | } 223 | 224 | // 获取新数据并更新缓存 225 | const hotNews = await getAllHotNews(); 226 | cache = { 227 | data: hotNews, 228 | timestamp: now 229 | }; 230 | 231 | return hotNews; 232 | } 233 | 234 | export async function GET() { 235 | try { 236 | console.error('Fetching hot news...'); 237 | const hotNews = await getCachedHotNews(); 238 | // console.error('Hot news data:', hotNews); 239 | return NextResponse.json(hotNews); 240 | } catch (error) { 241 | console.error('Error in hot news API:', error); 242 | return NextResponse.json({ error: 'Failed to fetch hot news' }, { status: 500 }); 243 | } 244 | } -------------------------------------------------------------------------------- /src/app/api/links/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/links/route.ts 2 | import { getLinks } from '@/lib/notion'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | export async function GET() { 6 | try { 7 | const links = await getLinks(); 8 | return NextResponse.json(links); 9 | } catch (error) { 10 | console.error('获取链接失败:', error); 11 | return NextResponse.json( 12 | { error: '获取链接失败' }, 13 | { status: 500 } 14 | ); 15 | } 16 | } -------------------------------------------------------------------------------- /src/app/api/weather/air/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function GET(request: Request) { 4 | const { searchParams } = new URL(request.url); 5 | const lat = searchParams.get('lat'); 6 | const lon = searchParams.get('lon'); 7 | const city = searchParams.get('city'); // 接收城市参数 8 | 9 | // 检查是否提供了必要的参数(经纬度或城市名) 10 | if ((!lat || !lon) && !city) { 11 | return NextResponse.json( 12 | { error: '缺少位置参数(需要提供lat和lon,或者city)' }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | try { 18 | // 使用和风天气API获取空气质量数据 19 | const apiKey = process.env.QWEATHER_API_KEY; 20 | if (!apiKey) { 21 | console.error('未配置和风天气API密钥'); 22 | return NextResponse.json( 23 | { error: '服务器配置错误' }, 24 | { status: 500 } 25 | ); 26 | } 27 | 28 | let apiUrl; 29 | let locationType = ''; 30 | 31 | // 修改:优先使用经纬度,仅当经纬度不完整时才使用城市名 32 | if (lat && lon) { 33 | // 使用正确的路径参数格式 34 | apiUrl = `https://devapi.qweather.com/airquality/v1/current/${lat}/${lon}?key=${apiKey}`; 35 | locationType = '经纬度'; 36 | } else if (city) { 37 | // 对于城市名,我们仍然使用查询参数格式 38 | apiUrl = `https://devapi.qweather.com/v7/air/now?location=${encodeURIComponent(city)}&key=${apiKey}`; 39 | locationType = '城市名'; 40 | } else { 41 | // 这种情况应该不会发生,因为前面已经检查过参数 42 | return NextResponse.json( 43 | { error: '缺少位置参数' }, 44 | { status: 400 } 45 | ); 46 | } 47 | 48 | console.error(`使用${locationType}请求空气质量数据: ${apiUrl.replace(apiKey, 'API_KEY')}`); 49 | 50 | const airResponse = await fetch(apiUrl); 51 | 52 | if (!airResponse.ok) { 53 | const errorText = await airResponse.text(); 54 | console.error('空气质量数据请求失败:', airResponse.status, errorText); 55 | throw new Error(`空气质量数据请求失败: ${airResponse.status} ${errorText}`); 56 | } 57 | 58 | const airData = await airResponse.json(); 59 | console.error('空气质量数据响应:', JSON.stringify(airData).substring(0, 200) + '...'); 60 | 61 | // 检查接口返回状态码 62 | if (airData.code && airData.code !== '200') { 63 | console.error('空气质量API返回错误:', airData.code, airData.message || '未知错误'); 64 | return NextResponse.json( 65 | { error: `空气质量数据获取失败: ${airData.message || '服务异常'}` }, 66 | { status: 500 } 67 | ); 68 | } 69 | 70 | // v7版本的API响应格式与v1不同,需要适配 71 | if (airData.now) { 72 | // 处理v7版本API响应 73 | const airNow = airData.now; 74 | const result = { 75 | aqi: parseInt(airNow.aqi), 76 | aqiDisplay: airNow.aqi, 77 | level: airNow.level, 78 | category: airNow.category, 79 | // 生成适当的颜色 80 | color: getColorByAqi(parseInt(airNow.aqi)), 81 | primaryPollutant: { 82 | code: airNow.primary, 83 | name: getPollutantName(airNow.primary), 84 | fullName: getPollutantFullName(airNow.primary) 85 | } 86 | }; 87 | 88 | console.error(`空气质量数据处理完成 (v7 API):`, result); 89 | return NextResponse.json(result); 90 | } 91 | 92 | // 处理v1版本API响应 93 | if (!airData.indexes || airData.indexes.length === 0) { 94 | console.error('空气质量数据响应异常:', JSON.stringify(airData)); 95 | return NextResponse.json( 96 | { error: '空气质量数据获取失败', detail: airData }, 97 | { status: 500 } 98 | ); 99 | } 100 | 101 | // 优先使用US-EPA标准,如果没有则使用QAQI 102 | const usEpaIndex = airData.indexes.find((index: { code: string }) => index.code === 'us-epa'); 103 | const qaqiIndex = airData.indexes.find((index: { code: string }) => index.code === 'qaqi'); 104 | 105 | // 使用找到的第一个指数,优先US-EPA 106 | const aqiIndex = usEpaIndex || qaqiIndex || airData.indexes[0]; 107 | 108 | // 返回处理后的空气质量数据 109 | const result = { 110 | aqi: aqiIndex.aqi, 111 | aqiDisplay: aqiIndex.aqiDisplay, 112 | level: aqiIndex.level, 113 | category: aqiIndex.category, 114 | color: aqiIndex.color, 115 | primaryPollutant: aqiIndex.primaryPollutant 116 | }; 117 | 118 | console.error(`空气质量数据处理完成 (v1 API):`, result); 119 | return NextResponse.json(result); 120 | } catch (error) { 121 | // 详细记录错误信息 122 | console.error('获取空气质量数据失败:', error instanceof Error ? error.message : String(error)); 123 | return NextResponse.json( 124 | { error: '获取空气质量数据失败', message: error instanceof Error ? error.message : String(error) }, 125 | { status: 500 } 126 | ); 127 | } 128 | } 129 | 130 | // 根据AQI值获取颜色 131 | function getColorByAqi(aqi: number) { 132 | if (aqi <= 50) { 133 | return { red: 0, green: 228, blue: 0, alpha: 1 }; // 绿色 134 | } else if (aqi <= 100) { 135 | return { red: 255, green: 255, blue: 0, alpha: 1 }; // 黄色 136 | } else if (aqi <= 150) { 137 | return { red: 255, green: 126, blue: 0, alpha: 1 }; // 橙色 138 | } else if (aqi <= 200) { 139 | return { red: 255, green: 0, blue: 0, alpha: 1 }; // 红色 140 | } else if (aqi <= 300) { 141 | return { red: 153, green: 0, blue: 76, alpha: 1 }; // 紫色 142 | } else { 143 | return { red: 126, green: 0, blue: 35, alpha: 1 }; // 褐红色 144 | } 145 | } 146 | 147 | // 获取污染物名称 148 | function getPollutantName(code: string) { 149 | const pollutantMap: Record = { 150 | 'pm2.5': 'PM2.5', 151 | 'pm25': 'PM2.5', 152 | 'pm10': 'PM10', 153 | 'no2': 'NO₂', 154 | 'so2': 'SO₂', 155 | 'o3': 'O₃', 156 | 'co': 'CO' 157 | }; 158 | 159 | return pollutantMap[code.toLowerCase()] || code; 160 | } 161 | 162 | // 获取污染物全称 163 | function getPollutantFullName(code: string) { 164 | const pollutantFullNameMap: Record = { 165 | 'pm2.5': '细颗粒物', 166 | 'pm25': '细颗粒物', 167 | 'pm10': '可吸入颗粒物', 168 | 'no2': '二氧化氮', 169 | 'so2': '二氧化硫', 170 | 'o3': '臭氧', 171 | 'co': '一氧化碳' 172 | }; 173 | 174 | return pollutantFullNameMap[code.toLowerCase()] || ''; 175 | } -------------------------------------------------------------------------------- /src/app/api/weather/geo/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | export async function GET(request: NextRequest) { 4 | const { searchParams } = new URL(request.url); 5 | const lat = searchParams.get('lat'); 6 | const lon = searchParams.get('lon'); 7 | 8 | if (!lat || !lon) { 9 | return NextResponse.json( 10 | { error: '缺少经纬度参数' }, 11 | { status: 400 } 12 | ); 13 | } 14 | 15 | try { 16 | // 使用和风天气API的地理位置查询服务 17 | const apiKey = process.env.QWEATHER_API_KEY; 18 | if (!apiKey) { 19 | console.error('未配置和风天气API密钥'); 20 | return NextResponse.json( 21 | { error: '服务器配置错误:未配置和风天气API密钥' }, 22 | { status: 500 } 23 | ); 24 | } 25 | 26 | console.error(`开始请求地理位置查询API...`); 27 | const response = await fetch( 28 | `https://geoapi.qweather.com/v2/city/lookup?location=${lon},${lat}&key=${apiKey}` 29 | ); 30 | 31 | if (!response.ok) { 32 | const errorText = await response.text(); 33 | console.error('地理位置API请求失败:', response.status, errorText); 34 | throw new Error(`地理位置API请求失败: ${response.status} ${errorText}`); 35 | } 36 | 37 | const data = await response.json(); 38 | console.error('地理位置API响应:', JSON.stringify(data).substring(0, 200) + '...'); 39 | 40 | if (data.code !== '200' || !data.location || data.location.length === 0) { 41 | console.error('地理位置API响应异常:', JSON.stringify(data)); 42 | return NextResponse.json( 43 | { location: '未知位置', error: '位置解析失败' }, 44 | { status: 200 } 45 | ); 46 | } 47 | 48 | // 返回城市名称 49 | const result = { location: data.location[0].name }; 50 | console.error('地理位置解析完成:', result); 51 | return NextResponse.json(result, { status: 200 }); 52 | } catch (error) { 53 | console.error('地理位置解析失败:', error instanceof Error ? error.message : String(error)); 54 | return NextResponse.json( 55 | { error: '地理位置解析失败', location: '未知位置', message: error instanceof Error ? error.message : String(error) }, 56 | { status: 500 } 57 | ); 58 | } 59 | } -------------------------------------------------------------------------------- /src/app/api/weather/ip/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | interface ServiceError extends Error { 4 | message: string; 5 | } 6 | 7 | export async function GET(request: NextRequest) { 8 | // 获取用户的真实IP地址 9 | let ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 10 | request.headers.get('x-real-ip') || 11 | '未知IP'; 12 | 13 | // 检查是否是本地开发环境中的保留IP地址 14 | const isReservedIP = ip === '127.0.0.1' || ip === 'localhost' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip === '未知IP'; 15 | 16 | // 如果是本地开发环境,使用备用公共IP进行测试 17 | if (isReservedIP && process.env.NODE_ENV === 'development') { 18 | // 使用一个公共IP作为开发环境中的备用方案 19 | ip = '8.8.8.8'; 20 | } 21 | 22 | // 尝试多个IP定位服务,提高可靠性 23 | const services = [ 24 | { 25 | name: 'ipapi.co', 26 | url: `https://ipapi.co/${encodeURIComponent(ip)}/json/`, 27 | transform: (data: any) => ({ 28 | ip: data.ip || ip, 29 | location: data.city ? `${data.city}` : '未知位置', 30 | country: data.country_name || '未知国家', 31 | latitude: data.latitude, 32 | longitude: data.longitude 33 | }) 34 | }, 35 | { 36 | name: 'ip-api.com', 37 | url: `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,message,country,regionName,city,query,lat,lon&lang=zh-CN`, 38 | transform: (data: any) => ({ 39 | ip: data.query || ip, 40 | location: data.city || '未知位置', 41 | country: data.country || '未知国家', 42 | latitude: data.lat, 43 | longitude: data.lon 44 | }) 45 | } 46 | ]; 47 | 48 | let lastError: ServiceError | null = null; 49 | 50 | // 依次尝试每个服务 51 | for (const service of services) { 52 | try { 53 | const response = await fetch(service.url, { 54 | cache: 'no-store', 55 | headers: { 56 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 57 | } 58 | }); 59 | 60 | if (!response.ok) { 61 | const statusText = response.statusText || '未知错误'; 62 | throw new Error(`${service.name} 服务响应错误: ${response.status} ${statusText}`); 63 | } 64 | 65 | const data = await response.json(); 66 | 67 | // 检查API特定的错误 68 | if (service.name === 'ip-api.com' && data.status === 'fail') { 69 | throw new Error(`${service.name} 返回错误: ${data.message}`); 70 | } 71 | 72 | // 转换数据并返回 73 | const result = service.transform(data); 74 | return NextResponse.json(result, { status: 200 }); 75 | } catch (error) { 76 | lastError = error as ServiceError; 77 | } 78 | } 79 | 80 | // 所有服务都失败了 81 | console.error('IP定位接口异常:', lastError); 82 | return NextResponse.json( 83 | { 84 | error: `IP定位失败: ${lastError?.message || '所有服务均不可用'}`, 85 | location: '未知位置' 86 | }, 87 | { status: 500 } 88 | ); 89 | } -------------------------------------------------------------------------------- /src/app/api/weather/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | export async function GET(request: NextRequest) { 4 | const { searchParams } = new URL(request.url); 5 | const city = searchParams.get('city'); 6 | const lat = searchParams.get('lat'); 7 | const lon = searchParams.get('lon'); 8 | 9 | // 检查是否提供了坐标或城市名 10 | if (!city && (!lat || !lon)) { 11 | return NextResponse.json( 12 | { error: '缺少位置参数(需要提供city或lat+lon)' }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | try { 18 | // 使用和风天气API获取天气数据 19 | const apiKey = process.env.QWEATHER_API_KEY; 20 | if (!apiKey) { 21 | console.error('未配置和风天气API密钥'); 22 | return NextResponse.json( 23 | { error: '服务器配置错误:未配置和风天气API密钥' }, 24 | { status: 500 } 25 | ); 26 | } 27 | 28 | // 确定位置参数 29 | let locationParam; 30 | if (lat && lon) { 31 | locationParam = `${lon},${lat}`; 32 | console.error(`使用经纬度查询: ${locationParam}`); 33 | } else { 34 | locationParam = city; 35 | console.error(`使用城市名查询: ${locationParam}`); 36 | } 37 | 38 | // 获取城市ID 39 | console.error(`开始请求城市查询API...`); 40 | const locationResponse = await fetch( 41 | `https://geoapi.qweather.com/v2/city/lookup?location=${encodeURIComponent(locationParam || '')}&key=${apiKey}` 42 | ); 43 | 44 | if (!locationResponse.ok) { 45 | const errorText = await locationResponse.text(); 46 | console.error('城市查询请求失败:', locationResponse.status, errorText); 47 | throw new Error(`城市查询请求失败: ${locationResponse.status} ${errorText}`); 48 | } 49 | 50 | const locationData = await locationResponse.json(); 51 | console.error('城市查询响应:', JSON.stringify(locationData).substring(0, 200) + '...'); 52 | 53 | if (locationData.code !== '200' || !locationData.location || locationData.location.length === 0) { 54 | console.error('城市查询响应异常:', JSON.stringify(locationData)); 55 | return NextResponse.json( 56 | { error: '找不到该位置', detail: locationData }, 57 | { status: 404 } 58 | ); 59 | } 60 | 61 | const locationId = locationData.location[0].id; 62 | const cityName = locationData.location[0].name; 63 | 64 | console.error(`找到位置: ${cityName}, ID: ${locationId}`); 65 | 66 | // 获取实时天气 67 | console.error(`开始请求实时天气数据...`); 68 | const weatherResponse = await fetch( 69 | `https://devapi.qweather.com/v7/weather/now?location=${locationId}&key=${apiKey}` 70 | ); 71 | 72 | if (!weatherResponse.ok) { 73 | const errorText = await weatherResponse.text(); 74 | console.error('天气数据请求失败:', weatherResponse.status, errorText); 75 | throw new Error(`天气数据请求失败: ${weatherResponse.status} ${errorText}`); 76 | } 77 | 78 | const weatherData = await weatherResponse.json(); 79 | console.error('天气数据响应:', JSON.stringify(weatherData).substring(0, 200) + '...'); 80 | 81 | if (weatherData.code !== '200') { 82 | console.error('天气数据响应异常:', JSON.stringify(weatherData)); 83 | return NextResponse.json( 84 | { error: '天气数据获取失败', detail: weatherData }, 85 | { status: 500 } 86 | ); 87 | } 88 | 89 | // 获取今日天气预报(最高温和最低温) 90 | console.error(`开始请求天气预报数据...`); 91 | const forecastResponse = await fetch( 92 | `https://devapi.qweather.com/v7/weather/3d?location=${locationId}&key=${apiKey}` 93 | ); 94 | 95 | if (!forecastResponse.ok) { 96 | const errorText = await forecastResponse.text(); 97 | console.error('天气预报请求失败:', forecastResponse.status, errorText); 98 | throw new Error(`天气预报请求失败: ${forecastResponse.status} ${errorText}`); 99 | } 100 | 101 | const forecastData = await forecastResponse.json(); 102 | console.error('天气预报响应:', JSON.stringify(forecastData).substring(0, 200) + '...'); 103 | 104 | let tempMin = null; 105 | let tempMax = null; 106 | 107 | if (forecastData.code === '200' && forecastData.daily && forecastData.daily.length > 0) { 108 | const minTemp = Number(forecastData.daily[0].tempMin); 109 | const maxTemp = Number(forecastData.daily[0].tempMax); 110 | tempMin = isNaN(minTemp) ? null : minTemp; 111 | tempMax = isNaN(maxTemp) ? null : maxTemp; 112 | } else { 113 | console.error('天气预报响应异常或为空:', JSON.stringify(forecastData)); 114 | } 115 | 116 | // 返回处理后的天气数据 117 | const result = { 118 | location: cityName, 119 | temp: isNaN(Number(weatherData.now.temp)) ? null : Number(weatherData.now.temp), 120 | text: weatherData.now.text || '未知', 121 | icon: weatherData.now.icon || '999', 122 | tempMin, 123 | tempMax 124 | }; 125 | 126 | console.error(`天气数据处理完成:`, result); 127 | return NextResponse.json(result); 128 | } catch (error) { 129 | // 详细记录错误信息 130 | console.error('获取天气数据失败:', error instanceof Error ? error.message : String(error)); 131 | return NextResponse.json( 132 | { error: '获取天气数据失败', message: error instanceof Error ? error.message : String(error) }, 133 | { status: 500 } 134 | ); 135 | } 136 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moyuguy/notion_bookmarks/23e3f95ab74dc5e2be4a9021744ce79c8d87379b/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5.9% 10%; 26 | --radius: 0.75rem; 27 | --highlight-bg: rgba(0, 0, 0, 0.05); 28 | } 29 | 30 | .dark { 31 | --background: 240 10% 3.9%; 32 | --foreground: 0 0% 98%; 33 | --card: 240 10% 3.9%; 34 | --card-foreground: 0 0% 98%; 35 | --popover: 240 10% 3.9%; 36 | --popover-foreground: 0 0% 98%; 37 | --primary: 0 0% 98%; 38 | --primary-foreground: 240 5.9% 10%; 39 | --secondary: 240 3.7% 15.9%; 40 | --secondary-foreground: 0 0% 98%; 41 | --muted: 240 3.7% 15.9%; 42 | --muted-foreground: 240 5% 64.9%; 43 | --accent: 240 3.7% 15.9%; 44 | --accent-foreground: 0 0% 98%; 45 | --destructive: 0 62.8% 30.6%; 46 | --destructive-foreground: 0 0% 98%; 47 | --border: 240 3.7% 15.9%; 48 | --input: 240 3.7% 15.9%; 49 | --ring: 240 4.9% 83.9%; 50 | --highlight-bg: rgba(255, 255, 255, 0.05); 51 | } 52 | } 53 | 54 | @layer base { 55 | * { 56 | @apply border-border; 57 | } 58 | body { 59 | @apply bg-background text-foreground; 60 | } 61 | } 62 | 63 | @keyframes highlight { 64 | 0% { 65 | background-color: transparent; 66 | } 67 | 20% { 68 | background-color: hsl(var(--primary) / 0.1); 69 | } 70 | 100% { 71 | background-color: transparent; 72 | } 73 | } 74 | 75 | .highlight-section { 76 | animation: highlight 1.5s ease-in-out; 77 | border-radius: 0.5rem; 78 | margin: -0.5rem; 79 | padding: 0.5rem; 80 | } 81 | 82 | /* 优化滚动行为 */ 83 | html { 84 | scroll-behavior: smooth; 85 | scroll-padding-top: 100px; /* 确保滚动时留出顶部空间 */ 86 | max-width: 100vw; 87 | overflow-x: hidden; 88 | } 89 | 90 | /* 添加平滑过渡效果 */ 91 | section { 92 | transition: background-color 0.3s ease-out; 93 | } 94 | 95 | html, body { 96 | max-width: 100vw; 97 | overflow-x: hidden; 98 | } 99 | 100 | @layer utilities { 101 | .animate-fade-in { 102 | animation: fadeIn 0.5s ease-out; 103 | } 104 | } 105 | 106 | @keyframes fadeIn { 107 | from { 108 | opacity: 0; 109 | transform: translateY(-20px); 110 | } 111 | to { 112 | opacity: 1; 113 | transform: translateY(0); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css" 2 | import "qweather-icons/font/qweather-icons.css" 3 | import "@/themes/theme.css" 4 | import { Inter } from "next/font/google" 5 | import { ThemeProvider } from "@/components/ui/ThemeProvider" 6 | import { Metadata } from "next" 7 | import { Clarity } from "@/components/analytics/Clarity" 8 | import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics" 9 | 10 | import { getWebsiteConfig } from "@/lib/notion" 11 | import { mergeConfig } from "@/config" 12 | 13 | const inter = Inter({ subsets: ["latin"] }) 14 | 15 | export const viewport = { 16 | width: "device-width", 17 | initialScale: 1, 18 | maximumScale: 1, 19 | } 20 | 21 | export async function generateMetadata(): Promise { 22 | const config = mergeConfig(await getWebsiteConfig()) 23 | 24 | return { 25 | metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), 26 | title: { 27 | default: config.SITE_TITLE || "Ezho导航", 28 | template: `%s | ${config.SITE_TITLE || "Ezho工具箱"}`, 29 | }, 30 | description: config.SITE_DESCRIPTION || "Ezho工具箱,超级个体需要知道的各种好用工具", 31 | keywords: config.SITE_KEYWORDS?.split(',') || ["AI导航", "超级个体", "工具箱"], 32 | icons: { 33 | icon: '/favicon.ico', 34 | }, 35 | openGraph: { 36 | type: 'website', 37 | locale: 'zh_CN', 38 | url: '/', 39 | title: config.SITE_TITLE, 40 | description: config.SITE_DESCRIPTION, 41 | siteName: config.SITE_TITLE, 42 | }, 43 | twitter: { 44 | card: 'summary_large_image', 45 | title: config.SITE_TITLE, 46 | description: config.SITE_DESCRIPTION, 47 | }, 48 | robots: { 49 | index: true, 50 | follow: true, 51 | googleBot: { 52 | index: true, 53 | follow: true, 54 | 'max-video-preview': -1, 55 | 'max-image-preview': 'large', 56 | 'max-snippet': -1, 57 | }, 58 | }, 59 | } 60 | } 61 | 62 | export default async function RootLayout({ 63 | children, 64 | }: { 65 | children: React.ReactNode 66 | }) { 67 | const config = mergeConfig(await getWebsiteConfig()) 68 | 69 | return ( 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | {children} 79 | 80 | 81 | 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | // src/app/page.tsx 2 | import LinkContainer from '@/components/layout/LinkContainer'; 3 | import Navigation from '@/components/layout/Navigation'; 4 | import { getLinks, getCategories, getWebsiteConfig } from '@/lib/notion'; 5 | import AnimatedMain from '@/components/layout/AnimatedMain'; 6 | import Footer from '@/components/layout/Footer'; 7 | import { SimpleTime, AnalogClock, Weather, IPInfo, HotNews } from '@/components/widgets'; 8 | import WidgetsContainer from '@/components/layout/WidgetsContainer'; 9 | import React from 'react'; 10 | 11 | export const revalidate = 43200; // 12小时重新验证一次 12 | 13 | export default async function HomePage() { 14 | // 获取数据 15 | const [notionCategories, links, config] = await Promise.all([ 16 | getCategories(), 17 | getLinks(), 18 | getWebsiteConfig(), 19 | ]); 20 | 21 | 22 | // 获取启用的分类名称集合 23 | const enabledCategories = new Set(notionCategories.map(cat => cat.name)); 24 | 25 | // 处理链接数据,只保留启用分类中的链接 26 | const processedLinks = links 27 | .map(link => ({ 28 | ...link, 29 | category1: link.category1 || '未分类', 30 | category2: link.category2 || '默认' 31 | })) 32 | .filter(link => enabledCategories.has(link.category1)); 33 | 34 | // 获取有链接的分类集合 35 | const categoriesWithLinks = new Set(processedLinks.map(link => link.category1)); 36 | 37 | // 过滤掉没有链接的分类 38 | const activeCategories = notionCategories.filter(category => 39 | categoriesWithLinks.has(category.name) 40 | ); 41 | 42 | // 为 Notion 分类添加子分类信息 43 | const categoriesWithSubs = activeCategories.map(category => { 44 | const subCategories = new Set( 45 | processedLinks 46 | .filter(link => link.category1 === category.name) 47 | .map(link => link.category2) 48 | ); 49 | 50 | return { 51 | ...category, 52 | subCategories: Array.from(subCategories).map(subCat => ({ 53 | id: subCat.toLowerCase().replace(/\s+/g, '-'), 54 | name: subCat 55 | })) 56 | }; 57 | }); 58 | 59 | const widgetMap: Record = { 60 | '简易时钟': , 61 | '圆形时钟': , 62 | '天气': , 63 | 'IP信息': , 64 | '热搜': , 65 | // 你可以继续扩展更多组件 66 | }; 67 | const widgetConfig = config.WIDGET_CONFIG?.split(',').map(s => s.trim()).filter(Boolean) ?? []; 68 | const widgets = widgetConfig 69 | .map((name, idx) => { 70 | const Comp = widgetMap[name]; 71 | if (!Comp) return null; 72 | return {Comp}; 73 | }) 74 | .filter(Boolean); 75 | 76 | return ( 77 |
78 | {/* 移动端顶部导航 */} 79 | 82 | {/* PC端侧边栏导航 */} 83 | 86 |
87 | {widgets.length > 0 && ( 88 |
89 | 90 | {widgets} 91 | 92 |
93 | )} 94 |
95 | 100 |
101 |
102 |
103 |
104 | ); 105 | } -------------------------------------------------------------------------------- /src/components/analytics/Clarity.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | // declare global { 6 | // interface Window { 7 | // clarity: any; 8 | // } 9 | // } 10 | 11 | export function Clarity({ clarityId }: { clarityId: string }) { 12 | useEffect(() => { 13 | if (typeof window !== 'undefined' && clarityId) { 14 | const script = document.createElement('script'); 15 | script.type = 'text/javascript'; 16 | script.text = ` 17 | (function(c,l,a,r,i,t,y){ 18 | c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; 19 | t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; 20 | y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); 21 | })(window, document, "clarity", "script", "${clarityId}"); 22 | `; 23 | document.head.appendChild(script); 24 | } 25 | }, [clarityId]); 26 | 27 | return null; 28 | } -------------------------------------------------------------------------------- /src/components/analytics/GoogleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Script from 'next/script'; 4 | 5 | interface GoogleAnalyticsProps { 6 | gaId: string; 7 | } 8 | 9 | export function GoogleAnalytics({ gaId }: GoogleAnalyticsProps) { 10 | if (!gaId) return null; 11 | 12 | return ( 13 | <> 14 | 26 | 27 | ); 28 | } -------------------------------------------------------------------------------- /src/components/analytics/index.ts: -------------------------------------------------------------------------------- 1 | export { Clarity } from './Clarity'; 2 | export { GoogleAnalytics } from './GoogleAnalytics'; -------------------------------------------------------------------------------- /src/components/layout/AnimatedMain.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion } from 'framer-motion' 4 | 5 | export default function AnimatedMain({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 13 | {children} 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /src/components/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { WebsiteConfig } from '@/types/notion' 4 | import { FaGithub, FaXTwitter, FaWeibo } from 'react-icons/fa6' 5 | import { FaBlogger } from 'react-icons/fa' 6 | import { cn } from '@/lib/utils' 7 | 8 | interface FooterProps { 9 | config: WebsiteConfig 10 | className?: string 11 | } 12 | 13 | export default function Footer({ config, className = "" }: FooterProps) { 14 | return ( 15 |
16 |
17 |
18 |
19 | {config.SOCIAL_GITHUB && ( 20 | 27 | 28 | 29 | )} 30 | {config.SOCIAL_BLOG && ( 31 | 38 | 39 | 40 | )} 41 | {config.SOCIAL_X && ( 42 | 49 | 50 | 51 | )} 52 | {config.SOCIAL_JIKE && ( 53 | 60 | 即刻 69 | 70 | )} 71 | {config.SOCIAL_WEIBO && ( 72 | 79 | 80 | 81 | )} 82 |
83 |
84 |

85 | Built with Next.js and Notion 86 |

87 |

88 | 2024 {config.SITE_AUTHOR}. All rights reserved. 89 |

90 |
91 |
92 |
93 |
94 | ) 95 | } -------------------------------------------------------------------------------- /src/components/layout/LinkContainer.tsx: -------------------------------------------------------------------------------- 1 | // src/components/LinkContainer.tsx 2 | "use client"; 3 | 4 | import React, { useState, useEffect } from "react"; 5 | import LinkCard from "@/components/ui/LinkCard"; 6 | import * as Icons from "lucide-react"; 7 | import { Link, Category } from '@/types/notion'; 8 | 9 | interface LinkContainerProps { 10 | initialLinks: Link[]; 11 | enabledCategories: Set; 12 | categories: Category[]; 13 | } 14 | 15 | export default function LinkContainer({ 16 | initialLinks, 17 | enabledCategories, 18 | categories, 19 | }: LinkContainerProps) { 20 | const [mounted, setMounted] = useState(false); 21 | const [currentTime, setCurrentTime] = useState(null); 22 | 23 | useEffect(() => { 24 | setMounted(true); 25 | setCurrentTime(new Date()); 26 | }, []); 27 | // 按一级和二级分类组织链接,只包含启用的分类 28 | const linksByCategory = initialLinks.reduce((acc, link) => { 29 | const cat1 = link.category1; 30 | const cat2 = link.category2; 31 | 32 | if (enabledCategories.has(cat1)) { 33 | if (!acc[cat1]) { 34 | acc[cat1] = {}; 35 | } 36 | if (!acc[cat1][cat2]) { 37 | acc[cat1][cat2] = []; 38 | } 39 | acc[cat1][cat2].push(link); 40 | } 41 | return acc; 42 | }, {} as Record>); 43 | 44 | const formatDate = (date: Date) => { 45 | return date.toLocaleString('zh-CN', { 46 | year: 'numeric', 47 | month: '2-digit', 48 | day: '2-digit', 49 | hour: '2-digit', 50 | minute: '2-digit', 51 | hour12: false 52 | }).replace(/\//g, '-'); 53 | }; 54 | 55 | return ( 56 |
57 | {categories.map((category) => { 58 | const categoryLinks = linksByCategory[category.name]; 59 | if (!categoryLinks) return null; 60 | 61 | return ( 62 |
63 |
64 | {category.iconName && 65 | Icons[category.iconName as keyof typeof Icons] ? ( 66 |
67 | {React.createElement( 68 | Icons[ 69 | category.iconName as keyof typeof Icons 70 | ] as React.ComponentType<{ className: string }>, 71 | { className: "w-5 h-5" } 72 | )} 73 |
74 | ) : null} 75 |

{category.name}

76 |
77 | 78 |
79 | {Object.entries(categoryLinks).map(([subCategory, links]) => ( 80 |
89 |
90 |
91 |

92 | {subCategory} 93 |

94 |
({links.length})
95 |
96 |
97 | {links.map((link) => ( 98 | 99 | ))} 100 |
101 |
102 | ))} 103 |
104 |
105 | ); 106 | })} 107 | {mounted && currentTime && ( 108 |
109 | 最近更新:{formatDate(currentTime)} 110 |
111 | )} 112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/components/layout/Navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { cn } from '@/lib/utils' 5 | import { ThemeSwitcher } from '@/components/ui/ThemeSwitcher' 6 | import * as Icons from 'lucide-react' 7 | import { WebsiteConfig } from '@/types/notion' 8 | import { useTheme } from 'next-themes' 9 | 10 | interface Category { 11 | id: string 12 | name: string 13 | iconName?: string 14 | subCategories: { 15 | id: string 16 | name: string 17 | }[] 18 | } 19 | 20 | interface NavigationProps { 21 | categories: Category[] 22 | config: WebsiteConfig 23 | } 24 | 25 | const defaultConfig: WebsiteConfig = { 26 | SOCIAL_GITHUB: '', 27 | SOCIAL_BLOG: '', 28 | SOCIAL_X: '', 29 | SOCIAL_JIKE: '', 30 | SOCIAL_WEIBO: '' 31 | } 32 | 33 | export default function Navigation({ categories, config = defaultConfig }: NavigationProps) { 34 | const [activeCategory, setActiveCategory] = useState('') 35 | const [expandedCategories, setExpandedCategories] = useState>(new Set()) 36 | const { theme } = useTheme(); // Get current theme 37 | 38 | const toggleCategory = (categoryId: string) => { 39 | setExpandedCategories(prev => { 40 | const next = new Set(prev) 41 | if (next.has(categoryId)) { 42 | next.delete(categoryId) 43 | } else { 44 | next.add(categoryId) 45 | } 46 | return next 47 | }) 48 | } 49 | 50 | // 处理导航点击 51 | const handleNavClick = (categoryId: string, subCategoryId?: string) => { 52 | setActiveCategory(categoryId); 53 | 54 | // 确保在客户端环境中执行DOM操作 55 | if (typeof window === 'undefined' || typeof document === 'undefined') return; 56 | 57 | const elementId = subCategoryId ? `${categoryId}-${subCategoryId}` : categoryId; 58 | const element = document.getElementById(elementId); 59 | 60 | if (element) { 61 | // 获取元素的位置 62 | const rect = element.getBoundingClientRect(); 63 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 64 | 65 | // 滚动到元素位置,减去顶部导航栏的高度(根据实际高度调整) 66 | window.scrollTo({ 67 | top: rect.top + scrollTop - 100, 68 | behavior: 'smooth' 69 | }); 70 | } 71 | }; 72 | 73 | // Set default active category on mount 74 | useEffect(() => { 75 | if (categories.length > 0 && activeCategory === '') { 76 | setActiveCategory(categories[0].id); 77 | } 78 | }, [categories, activeCategory]); 79 | 80 | return ( 81 | <> 82 | {/* 移动端顶部导航 */} 83 | 116 | 117 | {/* 桌面端边导航 */} 118 | 180 | 181 | ) 182 | } -------------------------------------------------------------------------------- /src/components/layout/WidgetsContainer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { motion } from 'framer-motion'; 5 | 6 | interface WidgetsContainerProps { 7 | children: React.ReactNode; 8 | } 9 | 10 | export default function WidgetsContainer({ children }: WidgetsContainerProps) { 11 | return ( 12 |
13 | 19 |
20 |
21 | {React.Children.map(children, (child, index) => ( 22 |
23 | {child} 24 |
25 | ))} 26 |
27 |
28 |
29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /src/components/ui/LinkCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import { Link } from '@/types/notion'; 5 | import { motion } from 'framer-motion'; 6 | import { IconExternalLink } from '@tabler/icons-react'; 7 | import { useState, useEffect, useRef } from 'react'; 8 | import { createPortal } from 'react-dom'; 9 | import { cn } from '@/lib/utils'; 10 | 11 | interface LinkCardProps { 12 | link: Link; 13 | className?: string; 14 | } 15 | 16 | // 提示框组件 17 | function Tooltip({ content, show, x, y }: { content: string; show: boolean; x: number; y: number }) { 18 | if (!show) return null; 19 | 20 | // 确保在客户端环境中执行 21 | if (typeof window === 'undefined' || typeof document === 'undefined') return null; 22 | 23 | return createPortal( 24 |
34 |

{content}

35 |
, 36 | document.body 37 | ); 38 | } 39 | 40 | // 获取图标URL的辅助函数 41 | function getIconUrl(link: Link): string { 42 | // 最优先使用iconfile 43 | if (link.iconfile) { 44 | return link.iconfile; 45 | } 46 | 47 | // 次优先级使用iconlink 48 | if (link.iconlink) { 49 | return link.iconlink; 50 | } 51 | 52 | // 如果都没有,尝试直接从网站获取 favicon 53 | try { 54 | const url = new URL(link.url); 55 | // 尝试多个可能的 favicon 路径 56 | const iconServices = [ 57 | `${url.origin}/favicon.ico`, // 最常见的 favicon 位置 58 | `${url.origin}/favicon.png`, // 有些网站使用 PNG 格式 59 | `https://api.iowen.cn/favicon/${url.hostname}.png` // 作为备选服务 60 | ]; 61 | return iconServices[0]; // 先尝试直接获取 62 | } catch (e) { 63 | // 如果 URL 解析失败,使用默认图标 64 | return '/globe.svg'; 65 | } 66 | } 67 | 68 | export default function LinkCard({ link, className }: LinkCardProps) { 69 | const [titleTooltip, setTitleTooltip] = useState({ show: false, x: 0, y: 0 }); 70 | const [descTooltip, setDescTooltip] = useState({ show: false, x: 0, y: 0 }); 71 | const [imageLoaded, setImageLoaded] = useState(false); 72 | const [imageSrc, setImageSrc] = useState(getIconUrl(link)); 73 | const [imageError, setImageError] = useState(false); 74 | const [currentServiceIndex, setCurrentServiceIndex] = useState(0); 75 | const imgRef = useRef(null); 76 | 77 | // 预加载图片 78 | useEffect(() => { 79 | if (imageSrc && imgRef.current) { 80 | const img = new window.Image(); 81 | img.src = imageSrc; 82 | img.onload = () => { 83 | if (imgRef.current) { 84 | imgRef.current.src = imageSrc; 85 | setImageLoaded(true); 86 | } 87 | }; 88 | img.onerror = () => { 89 | // 如果当前服务失败,尝试下一个服务 90 | try { 91 | const url = new URL(link.url); 92 | const iconServices = [ 93 | `${url.origin}/favicon.ico`, 94 | `${url.origin}/favicon.png`, 95 | `https://api.iowen.cn/favicon/${url.hostname}.png` 96 | ]; 97 | 98 | if (currentServiceIndex < iconServices.length - 1) { 99 | // 尝试下一个服务 100 | setCurrentServiceIndex(prev => prev + 1); 101 | setImageSrc(iconServices[currentServiceIndex + 1]); 102 | } else { 103 | // 所有服务都失败,使用默认图标 104 | setImageSrc('/globe.svg'); 105 | setImageError(true); 106 | setImageLoaded(true); 107 | } 108 | } catch (e) { 109 | setImageSrc('/globe.svg'); 110 | setImageError(true); 111 | setImageLoaded(true); 112 | } 113 | }; 114 | } 115 | }, [imageSrc, currentServiceIndex, link.url]); 116 | 117 | // 当 link 属性变化时重置图标状态 118 | useEffect(() => { 119 | setImageError(false); 120 | setImageLoaded(false); 121 | setCurrentServiceIndex(0); 122 | setImageSrc(getIconUrl(link)); 123 | }, [link]); 124 | 125 | const handleImageError = () => { 126 | setImageError(true); 127 | setImageLoaded(true); 128 | }; 129 | 130 | const handleMouseEnter = ( 131 | event: React.MouseEvent, 132 | setter: typeof setTitleTooltip 133 | ) => { 134 | const rect = event.currentTarget.getBoundingClientRect(); 135 | setter({ 136 | show: true, 137 | x: rect.left, 138 | y: rect.top 139 | }); 140 | }; 141 | 142 | const handleMouseLeave = (setter: typeof setTitleTooltip) => { 143 | setter({ show: false, x: 0, y: 0 }); 144 | }; 145 | 146 | return ( 147 | <> 148 | 161 | {/* 内容容器 */} 162 |
163 | {/* 图标和名称行 */} 164 |
165 | {/* 图标容器 */} 166 | 173 |
174 | Site Icon 185 | {!imageLoaded && ( 186 |
187 |
188 |
189 | )} 190 |
191 |
192 | 193 | {/* 网站名称和图标 */} 194 |
195 |
handleMouseEnter(e, setTitleTooltip)} 198 | onMouseLeave={() => handleMouseLeave(setTitleTooltip)} 199 | > 200 |

203 | {link.name} 204 |

205 |
206 | {/* 固定位置的外链图标 */} 207 |
208 | 211 |
212 |
213 |
214 | 215 | {/* 描述行 */} 216 | {link.desc && ( 217 |
handleMouseEnter(e, setDescTooltip)} 220 | onMouseLeave={() => handleMouseLeave(setDescTooltip)} 221 | > 222 |

225 | {link.desc} 226 |

227 |
228 | )} 229 | 230 | {/* 标签行 - 放在底部 */} 231 | {link.tags && link.tags.length > 0 && ( 232 |
233 | {link.tags.slice(0, 3).map((tag) => ( 234 | 242 | {tag} 243 | 244 | ))} 245 | {link.tags.length > 3 && ( 246 | 251 | +{link.tags.length - 3} 252 | 253 | )} 254 |
255 | )} 256 |
257 | 258 | {/* 渐变悬浮效果 */} 259 |
262 | 263 | 264 | {/* 提示框 */} 265 | 271 | {link.desc && ( 272 | 278 | )} 279 | 280 | ); 281 | } -------------------------------------------------------------------------------- /src/components/ui/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ children }: { children: React.ReactNode }) { 7 | return ( 8 | 13 | {children} 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /src/components/ui/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { motion } from "framer-motion" 5 | import { IconPalette } from "@tabler/icons-react" 6 | import { useTheme } from "next-themes" 7 | 8 | interface ThemeSwitcherProps { 9 | className?: string 10 | } 11 | 12 | const themeNames = { 13 | 'simple-light': '简约浅色', 14 | 'simple-dark': '简约深色', 15 | 'cyberpunk-dark': '赛博朋克' 16 | } 17 | 18 | export function ThemeSwitcher({ className }: ThemeSwitcherProps) { 19 | const [mounted, setMounted] = React.useState(false) 20 | const [isOpen, setIsOpen] = React.useState(false) 21 | const { theme, setTheme } = useTheme() 22 | const dropdownRef = React.useRef(null) 23 | const buttonRef = React.useRef(null) 24 | 25 | // 处理点击外部关闭下拉菜单 26 | React.useEffect(() => { 27 | const handleClickOutside = (event: MouseEvent) => { 28 | if ( 29 | dropdownRef.current && 30 | buttonRef.current && 31 | !dropdownRef.current.contains(event.target as Node) && 32 | !buttonRef.current.contains(event.target as Node) 33 | ) { 34 | setIsOpen(false) 35 | } 36 | } 37 | 38 | document.addEventListener('click', handleClickOutside) 39 | return () => document.removeEventListener('click', handleClickOutside) 40 | }, [isOpen]) 41 | 42 | // 确保在客户端渲染 43 | React.useEffect(() => { 44 | setMounted(true) 45 | }, []) 46 | 47 | if (!mounted) { 48 | return null 49 | } 50 | 51 | return ( 52 |
53 | { 58 | e.stopPropagation() 59 | setIsOpen(!isOpen) 60 | }} 61 | className="p-2 rounded-lg text-muted-foreground hover:text-foreground transition-colors" 62 | aria-label="切换主题" 63 | > 64 | 65 | 66 | 67 | {isOpen && ( 68 |
e.stopPropagation()} 72 | > 73 | {Object.entries(themeNames).map(([name, displayName]) => ( 74 | 87 | ))} 88 |
89 | )} 90 |
91 | ) 92 | } -------------------------------------------------------------------------------- /src/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | // UI组件导出文件 2 | export * from './ThemeSwitcher'; -------------------------------------------------------------------------------- /src/components/widgets/AnalogClock.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { motion } from 'framer-motion'; 5 | 6 | export default function AnalogClock() { 7 | // Initialize with null to avoid hydration mismatch 8 | const [time, setTime] = useState(null); 9 | 10 | useEffect(() => { 11 | // Set initial time only on client side 12 | setTime(new Date()); 13 | 14 | const timer = setInterval(() => { 15 | setTime(new Date()); 16 | }, 1000); 17 | 18 | return () => clearInterval(timer); 19 | }, []); 20 | 21 | // Don't render clock hands until client-side time is available 22 | if (!time) { 23 | return ( 24 |
25 |
26 | {/* Static clock face without hands */} 27 |
28 |
29 | ); 30 | } 31 | 32 | // 计算指针角度 33 | const secondsDegrees = (time.getSeconds() / 60) * 360; 34 | const minutesDegrees = ((time.getMinutes() + time.getSeconds() / 60) / 60) * 360; 35 | const hoursDegrees = ((time.getHours() % 12 + time.getMinutes() / 60) / 12) * 360; 36 | 37 | return ( 38 | 44 | {/* 背景装饰 - 主题感知 */} 45 |
46 | 47 |
48 | {/* 时钟刻度 - 调整为靠近边缘但不与数字重叠 */} 49 | {[...Array(12)].map((_, i) => ( 50 |
62 | ))} 63 | 64 | {/* 时钟数字 - 放置在距离刻度更远的位置 */} 65 | {[...Array(12)].map((_, i) => { 66 | const number = i === 0 ? 12 : i; 67 | const angle = i * 30; 68 | const radian = (angle - 90) * (Math.PI / 180); 69 | const radius = 40; // 更靠近中心,避免与刻度重叠 70 | 71 | // 计算数字的位置 72 | const x = radius * Math.cos(radian); 73 | const y = radius * Math.sin(radian); 74 | 75 | return ( 76 |
86 | {number} 87 |
88 | ); 89 | })} 90 | 91 | {/* 时针 */} 92 |
101 | 102 | {/* 分针 */} 103 |
112 | 113 | {/* 秒针 - 缩短长度确保不超出表盘 */} 114 |
123 | 124 | {/* 中心点 */} 125 |
126 |
127 | 128 | ); 129 | } -------------------------------------------------------------------------------- /src/components/widgets/HotNews.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, useCallback, useRef } from 'react'; 4 | import { ExternalLink } from 'lucide-react'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | interface HotNewsItem { 8 | title: string; 9 | url: string; 10 | views: string; 11 | platform: string; 12 | } 13 | 14 | const platforms = [ 15 | { id: 'weibo', name: '微博' }, 16 | { id: 'baidu', name: '百度' }, 17 | { id: 'bilibili', name: '哔哩哔哩' }, 18 | { id: 'toutiao', name: '今日头条' }, 19 | { id: 'douyin', name: '抖音' } 20 | ]; 21 | 22 | export default function HotNews() { 23 | const [activePlatform, setActivePlatform] = useState('weibo'); 24 | const [news, setNews] = useState([]); 25 | const [loading, setLoading] = useState(true); 26 | const [error, setError] = useState(null); 27 | const [allNews, setAllNews] = useState>({}); 28 | const lastFetchTime = useRef(0); 29 | const CACHE_TIME = 15 * 60 * 1000; // 15分钟 30 | 31 | // 获取数据的函数 32 | const fetchHotNews = useCallback(async (force = false) => { 33 | const now = Date.now(); 34 | // 如果数据在缓存时间内且不是强制刷新,直接使用缓存数据 35 | if (!force && now - lastFetchTime.current < CACHE_TIME && Object.keys(allNews).length > 0) { 36 | setNews(allNews[activePlatform] || []); 37 | return; 38 | } 39 | 40 | try { 41 | setLoading(true); 42 | setError(null); 43 | const response = await fetch('/api/hot-news'); 44 | if (!response.ok) { 45 | throw new Error('获取热搜数据失败'); 46 | } 47 | const data = await response.json(); 48 | setAllNews(data); 49 | setNews(data[activePlatform] || []); 50 | lastFetchTime.current = now; 51 | } catch (error) { 52 | console.error('Failed to fetch hot news:', error); 53 | setError('获取热搜数据失败,请稍后重试'); 54 | } finally { 55 | setLoading(false); 56 | } 57 | }, []); 58 | 59 | // 当平台切换时更新显示的数据 60 | useEffect(() => { 61 | if (allNews[activePlatform]) { 62 | setNews(allNews[activePlatform]); 63 | } 64 | }, [activePlatform, allNews]); 65 | 66 | // 自动轮播 67 | useEffect(() => { 68 | const autoRotate = () => { 69 | const currentIndex = platforms.findIndex(p => p.id === activePlatform); 70 | const nextIndex = (currentIndex + 1) % platforms.length; 71 | setActivePlatform(platforms[nextIndex].id); 72 | }; 73 | 74 | // 每30秒切换一次平台 75 | const interval = setInterval(autoRotate, 30000); 76 | 77 | // 当用户手动切换平台时,重置定时器 78 | return () => clearInterval(interval); 79 | }, [activePlatform]); 80 | 81 | // 获取数据 82 | useEffect(() => { 83 | fetchHotNews(); 84 | // 每15分钟刷新一次数据 85 | const refreshInterval = setInterval(() => fetchHotNews(true), 15 * 60 * 1000); 86 | return () => clearInterval(refreshInterval); 87 | }, [fetchHotNews]); 88 | 89 | return ( 90 |
91 | {/* 平台选择器 - 减小内边距和间距 */} 92 |
93 | {platforms.map(platform => ( 94 | 106 | ))} 107 |
108 | 109 | {/* 热搜列表 - 优化间距和布局 */} 110 |
111 | {loading ? ( 112 |
113 |
114 |

获取热搜数据...

115 |
116 | ) : error ? ( 117 |
118 | {error} 119 |
120 | ) : news.length === 0 ? ( 121 |
122 | 暂无数据 123 |
124 | ) : ( 125 | news.map((item, index) => ( 126 | 133 | 2 140 | } 141 | )}> 142 | {index + 1} 143 | 144 | 145 |
146 | 147 | {item.title} 148 | 149 | 150 | {item.views} 151 | 152 |
153 |
154 | )) 155 | )} 156 |
157 |
158 | ); 159 | } -------------------------------------------------------------------------------- /src/components/widgets/IPInfo.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | 5 | interface IPData { 6 | ip: string; 7 | location: string; 8 | } 9 | 10 | // 将 WebRTC 相关函数移到组件外部 11 | const getLocalIP = () => { 12 | return new Promise((resolve, reject) => { 13 | // 确保在客户端环境中执行 14 | if (typeof window === 'undefined') { 15 | reject(new Error('服务端环境不支持WebRTC')); 16 | return; 17 | } 18 | 19 | const RTCPeerConnection = window.RTCPeerConnection || 20 | (window as any).webkitRTCPeerConnection || 21 | (window as any).mozRTCPeerConnection; 22 | 23 | if (!RTCPeerConnection) { 24 | reject(new Error('WebRTC not supported')); 25 | return; 26 | } 27 | 28 | const pc = new RTCPeerConnection({ 29 | iceServers: [ 30 | { urls: 'stun:stun.l.google.com:19302' }, 31 | { urls: 'stun:stun1.l.google.com:19302' }, 32 | { urls: 'stun:stun2.l.google.com:19302' }, 33 | { urls: 'stun:stun.cloudflare.com:3478' }, 34 | { urls: 'stun:stun.nextcloud.com:443' }, 35 | { urls: 'stun:stun.sipgate.net:3478' } 36 | ] 37 | }); 38 | 39 | pc.createDataChannel(''); 40 | pc.createOffer() 41 | .then(offer => pc.setLocalDescription(offer)) 42 | .catch(err => { 43 | pc.close(); 44 | reject(err); 45 | }); 46 | 47 | let foundIP = false; 48 | let fallbackIP: string | null = null; 49 | 50 | pc.onicecandidate = (ice) => { 51 | if (!ice || !ice.candidate || !ice.candidate.candidate) { 52 | return; 53 | } 54 | 55 | const candidate = ice.candidate.candidate; 56 | const match = candidate.match(/([0-9]{1,3}(\.[0-9]{1,3}){3})/); 57 | if (match) { 58 | const ip = match[1]; 59 | const ipParts = ip.split('.'); 60 | const isValidIP = ipParts.length === 4 && 61 | ipParts.every(part => { 62 | const num = parseInt(part); 63 | return num >= 0 && num <= 255; 64 | }); 65 | 66 | if (isValidIP) { 67 | // 优先使用公网IP(本机真实IP,不受VPN影响) 68 | if (!ip.startsWith('192.168.') && !ip.startsWith('10.') && !ip.startsWith('172.')) { 69 | foundIP = true; 70 | pc.onicecandidate = null; 71 | pc.close(); 72 | resolve(ip); 73 | } else if (!fallbackIP) { 74 | // 保存内网IP作为备选 75 | fallbackIP = ip; 76 | } 77 | } 78 | } 79 | }; 80 | 81 | setTimeout(() => { 82 | if (!foundIP) { 83 | pc.onicecandidate = null; 84 | pc.close(); 85 | if (fallbackIP) { 86 | // 如果没有找到公网IP,使用内网IP 87 | resolve(fallbackIP); 88 | } else { 89 | reject(new Error('获取本地IP超时')); 90 | } 91 | } 92 | }, 8000); // 增加超时时间到8秒 93 | }); 94 | }; 95 | 96 | export default function IPInfo() { 97 | const [hasMounted, setHasMounted] = useState(false); 98 | const [currentIP, setCurrentIP] = useState({ ip: '获取中...', location: '获取中...' }); 99 | const [proxyIP, setProxyIP] = useState({ ip: '获取中...', location: '获取中...' }); 100 | const [currentIPError, setCurrentIPError] = useState(null); 101 | const [proxyIPError, setProxyIPError] = useState(null); 102 | const [loading, setLoading] = useState(true); 103 | 104 | const fetchIPInfo = async () => { 105 | // 确保在客户端环境中执行 106 | if (typeof window === 'undefined') return; 107 | 108 | setLoading(true); 109 | setCurrentIPError(null); 110 | setProxyIPError(null); 111 | 112 | // 获取当前IP 113 | const fetchCurrentIP = async () => { 114 | try { 115 | const localIP = await getLocalIP(); 116 | 117 | // 获取本地IP的位置信息 118 | const currentLocationResponse = await fetch(`https://ipapi.co/${localIP}/json/`, { 119 | headers: { 'Accept': 'application/json' } 120 | }); 121 | 122 | if (!currentLocationResponse.ok) { 123 | throw new Error('获取位置信息失败'); 124 | } 125 | 126 | const currentLocationData = await currentLocationResponse.json(); 127 | 128 | setCurrentIP({ 129 | ip: localIP, 130 | location: currentLocationData.country_name && currentLocationData.city 131 | ? `${currentLocationData.city}, ${currentLocationData.country_name}` 132 | : '未知位置' 133 | }); 134 | } catch (error) { 135 | console.error('Failed to fetch current IP:', error); 136 | setCurrentIPError(error instanceof Error ? error.message : '获取当前IP失败'); 137 | setCurrentIP({ ip: '未获取到', location: '未获取到' }); 138 | } 139 | }; 140 | 141 | // 获取代理IP 142 | const fetchProxyIP = async () => { 143 | try { 144 | const proxyResponse = await fetch('https://api.ipify.org?format=json', { 145 | headers: { 'Accept': 'application/json' } 146 | }); 147 | 148 | if (!proxyResponse.ok) { 149 | throw new Error('获取代理IP失败'); 150 | } 151 | 152 | const proxyData = await proxyResponse.json(); 153 | 154 | if (!proxyData.ip) { 155 | throw new Error('无法获取代理IP'); 156 | } 157 | 158 | // 获取代理IP的位置信息 159 | const proxyLocationResponse = await fetch(`https://ipapi.co/${proxyData.ip}/json/`, { 160 | headers: { 'Accept': 'application/json' } 161 | }); 162 | 163 | if (!proxyLocationResponse.ok) { 164 | throw new Error('获取代理位置信息失败'); 165 | } 166 | 167 | const proxyLocationData = await proxyLocationResponse.json(); 168 | 169 | setProxyIP({ 170 | ip: proxyData.ip, 171 | location: proxyLocationData.country_name && proxyLocationData.city 172 | ? `${proxyLocationData.city}, ${proxyLocationData.country_name}` 173 | : '未知位置' 174 | }); 175 | } catch (error) { 176 | console.error('Failed to fetch proxy IP:', error); 177 | setProxyIPError(error instanceof Error ? error.message : '获取代理IP失败'); 178 | setProxyIP({ ip: '未获取到', location: '未获取到' }); 179 | } 180 | }; 181 | 182 | // 并行获取两个IP信息 183 | await Promise.all([fetchCurrentIP(), fetchProxyIP()]); 184 | 185 | setLoading(false); 186 | }; 187 | 188 | useEffect(() => { 189 | setHasMounted(true); 190 | }, []); 191 | 192 | useEffect(() => { 193 | if (!hasMounted) return; 194 | 195 | fetchIPInfo(); 196 | }, [hasMounted]); 197 | 198 | const handleRetry = () => { 199 | if (!hasMounted || typeof window === 'undefined') return; 200 | 201 | setLoading(true); 202 | setCurrentIPError(null); 203 | setProxyIPError(null); 204 | setCurrentIP({ ip: '获取中...', location: '获取中...' }); 205 | setProxyIP({ ip: '获取中...', location: '获取中...' }); 206 | 207 | // 重新触发数据获取 208 | fetchIPInfo(); 209 | }; 210 | 211 | // 服务端渲染时显示加载状态 212 | if (!hasMounted) { 213 | return ( 214 |
215 |
216 |
217 |
218 |

获取IP信息...

219 |
220 |
221 | ); 222 | } 223 | 224 | return ( 225 |
226 |
227 | 228 | {loading ? ( 229 |
230 |
231 |

获取IP信息...

232 |
233 | ) : (currentIPError && proxyIPError) ? ( 234 | <> 235 |
236 |
237 |

获取失败

238 |
239 |
240 |
241 | ⚠️ 242 |
243 |
244 |
245 | 246 |
247 |

获取IP信息失败,请稍后重试

248 | 254 |
255 | 256 | ) : ( 257 | <> 258 |
259 |
260 |
261 | 262 |
263 |
264 |
265 | 266 |
267 |
268 |
269 |

当前IP

270 | {currentIPError ? ( 271 |

未查询到当前IP信息

272 | ) : ( 273 | <> 274 |

{currentIP.ip}

275 |

{currentIP.location}

276 | 277 | )} 278 |
279 |
280 |

代理IP

281 | {proxyIPError ? ( 282 |

未查询到代理IP信息

283 | ) : ( 284 | <> 285 |

{proxyIP.ip}

286 |

{proxyIP.location}

287 | 288 | )} 289 |
290 |
291 |
292 | 293 | )} 294 |
295 | ); 296 | } -------------------------------------------------------------------------------- /src/components/widgets/SimpleTime.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, useRef } from 'react'; 4 | import { motion } from 'framer-motion'; 5 | import Lunar from 'lunar-javascript'; 6 | 7 | export default function SimpleTime() { 8 | // 使用 null 初始状态,避免服务端和客户端渲染不一致 9 | const [mounted, setMounted] = useState(false); 10 | const [time, setTime] = useState(null); 11 | const [lunarDate, setLunarDate] = useState(''); 12 | 13 | // 使用 ref 存储当前日期,避免无限循环 14 | const dateRef = useRef({ 15 | day: 0, 16 | month: 0, 17 | year: 0 18 | }); 19 | 20 | // 农历日期转换函数 21 | function getLunarDate(date: Date): string { 22 | try { 23 | // 使用 lunar-javascript 库计算农历日期 24 | const { Solar } = Lunar; 25 | const lunar = Solar.fromDate(date).getLunar(); 26 | return `农历 ${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`; 27 | } catch (error) { 28 | console.error('Error calculating lunar date:', error); 29 | return '农历日期获取失败'; 30 | } 31 | } 32 | 33 | useEffect(() => { 34 | // 客户端挂载后,设置为当前时间并开始计时 35 | setMounted(true); 36 | const now = new Date(); 37 | setTime(now); 38 | setLunarDate(getLunarDate(now)); 39 | 40 | // 初始化日期引用 41 | dateRef.current = { 42 | day: now.getDate(), 43 | month: now.getMonth(), 44 | year: now.getFullYear() 45 | }; 46 | 47 | const timer = setInterval(() => { 48 | const currentTime = new Date(); 49 | setTime(currentTime); 50 | 51 | // 只在日期变化时更新农历日期,避免不必要的计算 52 | if (currentTime.getDate() !== dateRef.current.day || 53 | currentTime.getMonth() !== dateRef.current.month || 54 | currentTime.getFullYear() !== dateRef.current.year) { 55 | 56 | // 更新引用中存储的日期 57 | dateRef.current = { 58 | day: currentTime.getDate(), 59 | month: currentTime.getMonth(), 60 | year: currentTime.getFullYear() 61 | }; 62 | 63 | setLunarDate(getLunarDate(currentTime)); 64 | } 65 | }, 1000); 66 | 67 | return () => clearInterval(timer); 68 | }, []); // 依赖数组为空,只在组件挂载时执行一次 69 | 70 | // 日期格式化 - 只有在 time 存在时才进行 71 | const year = time?.getFullYear() || 0; 72 | const month = (time?.getMonth() || 0) + 1; 73 | const day = time?.getDate() || 0; 74 | const hours = (time?.getHours() || 0).toString().padStart(2, '0'); 75 | const minutes = (time?.getMinutes() || 0).toString().padStart(2, '0'); 76 | const seconds = (time?.getSeconds() || 0).toString().padStart(2, '0'); 77 | 78 | // 获取星期几 79 | const weekDays = ['日', '一', '二', '三', '四', '五', '六']; 80 | const weekDay = weekDays[time?.getDay() || 0]; 81 | 82 | // 如果未挂载或时间未初始化,返回加载状态 83 | if (!mounted || !time) { 84 | return ( 85 |
86 |
87 |
88 |
89 |
90 | --月 91 |
92 |
93 | -- 94 |
95 |
96 | 97 |
98 |
星期-
99 |
农历 --月--
100 |
--:--:--
101 |
102 |
103 |
104 |
105 | ); 106 | } 107 | 108 | return ( 109 |
110 | 116 | {/* 背景装饰 - 主题感知 */} 117 |
118 | 119 |
120 | {/* 左侧日历 */} 121 |
122 |
123 | {month}月 124 |
125 |
126 | {day} 127 |
128 |
129 | 130 | {/* 右侧信息 */} 131 |
132 |
星期{weekDay}
133 |
{lunarDate}
134 |
{hours}:{minutes}:{seconds}
135 |
136 |
137 |
138 |
139 | ); 140 | } -------------------------------------------------------------------------------- /src/components/widgets/Weather.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, useRef } from 'react'; 4 | import { motion } from 'framer-motion'; 5 | import { createPortal } from 'react-dom'; 6 | 7 | interface WeatherData { 8 | location: string; 9 | temperature: number; 10 | condition: string; 11 | icon: string; 12 | tempMin: number; 13 | tempMax: number; 14 | // 添加空气质量相关字段 15 | aqi?: number; 16 | aqiDisplay?: string; 17 | aqiLevel?: string; 18 | aqiCategory?: string; 19 | aqiColor?: { 20 | red: number; 21 | green: number; 22 | blue: number; 23 | alpha: number; 24 | }; 25 | primaryPollutant?: { 26 | code: string; 27 | name: string; 28 | fullName?: string; 29 | }; 30 | } 31 | 32 | interface WeatherProps { 33 | defaultCity?: string; 34 | } 35 | 36 | // 空气质量级别对应的中文名称 37 | const aqiCategoryMap: Record = { 38 | 'Good': '优', 39 | 'Moderate': '良', 40 | 'Unhealthy for Sensitive Groups': '轻度污染', 41 | 'Unhealthy': '中度污染', 42 | 'Very Unhealthy': '重度污染', 43 | 'Hazardous': '严重污染', 44 | 'Excellent': '优' 45 | }; 46 | 47 | // 添加空气质量类型定义 48 | type AqiCategory = '优' | '良' | '轻度污染' | '中度污染' | '重度污染' | '严重污染'; 49 | 50 | const airQualityStyles: Record = { 51 | '优': 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300', 52 | '良': 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300', 53 | '轻度污染': 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-300', 54 | '中度污染': 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300', 55 | '重度污染': 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300', 56 | '严重污染': 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300' 57 | }; 58 | 59 | export default function Weather({ defaultCity = '杭州' }: WeatherProps) { 60 | // 1. 首先声明所有的状态 Hooks 61 | const [mounted, setMounted] = useState(false); 62 | const [weatherData, setWeatherData] = useState(null); 63 | const [loading, setLoading] = useState(true); 64 | const [error, setError] = useState(null); 65 | const [showCitySelector, setShowCitySelector] = useState(false); 66 | const [customCity, setCustomCity] = useState(''); 67 | const [currentCity, setCurrentCity] = useState(defaultCity); 68 | const [savedCity, setSavedCity] = useState(null); 69 | const [isRefreshing, setIsRefreshing] = useState(false); 70 | const [portalContainer, setPortalContainer] = useState(null); 71 | 72 | // 2. 声明所有的 refs 73 | const cityInputRef = useRef(null); 74 | const menuRef = useRef(null); 75 | const buttonRef = useRef(null); 76 | 77 | // 3. 声明所有的 effects 78 | useEffect(() => { 79 | setMounted(true); 80 | }, []); 81 | 82 | useEffect(() => { 83 | if (!mounted) return; 84 | 85 | setPortalContainer(document.body); 86 | 87 | // 从localStorage获取保存的城市 88 | const storedCity = localStorage.getItem('weatherCity'); 89 | if (storedCity) { 90 | setSavedCity(storedCity); 91 | setCurrentCity(storedCity); 92 | } 93 | }, [mounted]); 94 | 95 | useEffect(() => { 96 | if (!mounted) return; 97 | 98 | fetchWeatherData('auto'); 99 | 100 | // 每30分钟更新一次天气数据 101 | const intervalId = setInterval(() => fetchWeatherData('auto'), 30 * 60 * 1000); 102 | 103 | return () => clearInterval(intervalId); 104 | }, [mounted, defaultCity]); 105 | 106 | // 4. 声明所有的处理函数 107 | const fetchWeatherData = async (city: string) => { 108 | if (!mounted) return; 109 | 110 | setLoading(true); 111 | setError(null); 112 | 113 | // 如果是自动获取位置,设置刷新标志 114 | if (city === 'auto') { 115 | setIsRefreshing(true); 116 | } 117 | 118 | try { 119 | // 尝试获取用户位置 120 | let location = city; 121 | let locationSource = '自定义城市'; 122 | let latitude: number | null = null; 123 | let longitude: number | null = null; 124 | 125 | if (city === 'auto') { 126 | // 优先使用保存的城市 127 | if (savedCity) { 128 | location = savedCity; 129 | locationSource = '记忆城市'; 130 | } else { 131 | location = defaultCity; 132 | locationSource = '默认城市'; 133 | 134 | try { 135 | // 先尝试使用浏览器定位 136 | if (typeof navigator !== 'undefined' && navigator.geolocation) { 137 | const position = await new Promise((resolve, reject) => { 138 | navigator.geolocation.getCurrentPosition(resolve, reject, { 139 | timeout: 10000, 140 | maximumAge: 600000 // 10分钟缓存 141 | }); 142 | }); 143 | 144 | // 使用经纬度获取城市信息 145 | latitude = position.coords.latitude; 146 | longitude = position.coords.longitude; 147 | const geoResponse = await fetch(`/api/weather/geo?lat=${latitude}&lon=${longitude}`); 148 | 149 | if (!geoResponse.ok) throw new Error('无法获取位置信息'); 150 | 151 | const geoData = await geoResponse.json(); 152 | if (geoData.location && geoData.location !== '未知位置') { 153 | location = geoData.location; 154 | locationSource = '位置服务'; 155 | } 156 | } 157 | 158 | // 如果浏览器定位失败,尝试使用 IP 定位 159 | if (location === defaultCity) { 160 | const ipResponse = await fetch('/api/weather/ip'); 161 | 162 | if (ipResponse.ok) { 163 | const ipData = await ipResponse.json(); 164 | 165 | if (ipData.location && ipData.location !== '未知位置') { 166 | location = ipData.location; 167 | locationSource = 'IP定位'; 168 | // 如果IP定位返回了经纬度,保存下来 169 | if (ipData.latitude && ipData.longitude) { 170 | latitude = ipData.latitude; 171 | longitude = ipData.longitude; 172 | } 173 | } 174 | } 175 | } 176 | } catch (error) { 177 | console.error('位置获取失败:', error); 178 | // 使用默认城市 179 | location = defaultCity; 180 | locationSource = '默认城市'; 181 | } 182 | } 183 | } else { 184 | // 如果是手动选择的城市,保存到localStorage 185 | if (city !== 'auto' && city !== defaultCity && typeof localStorage !== 'undefined') { 186 | localStorage.setItem('weatherCity', city); 187 | setSavedCity(city); 188 | } 189 | } 190 | 191 | setCurrentCity(location); 192 | 193 | // 获取天气数据 194 | const weatherResponse = await fetch(`/api/weather?city=${encodeURIComponent(location)}`); 195 | 196 | if (!weatherResponse.ok) { 197 | const status = weatherResponse.status; 198 | if (status === 404) { 199 | throw new Error(`找不到城市 ${location} 的天气数据`); 200 | } else { 201 | throw new Error(`天气数据获取失败 - HTTP状态码: ${status}`); 202 | } 203 | } 204 | 205 | const data = await weatherResponse.json(); 206 | 207 | // 检查API返回的错误信息 208 | if (data.error) { 209 | throw new Error(data.error); 210 | } 211 | 212 | // 创建天气数据对象 213 | const weatherDataObj: WeatherData = { 214 | location: data.location || location, 215 | temperature: data.temp, 216 | condition: data.text, 217 | icon: data.icon, 218 | tempMin: data.tempMin, 219 | tempMax: data.tempMax 220 | }; 221 | 222 | // 如果有经纬度,尝试获取空气质量数据 223 | if (location) { 224 | try { 225 | // 如果有经纬度,优先使用经纬度,否则使用城市名 226 | let airUrl = latitude && longitude 227 | ? `/api/weather/air?lat=${latitude}&lon=${longitude}&city=${encodeURIComponent(location)}` 228 | : `/api/weather/air?city=${encodeURIComponent(location)}`; 229 | 230 | const airResponse = await fetch(airUrl); 231 | 232 | if (airResponse.ok) { 233 | const airData = await airResponse.json(); 234 | 235 | if (!airData.error) { 236 | // 添加空气质量数据 237 | weatherDataObj.aqi = airData.aqi; 238 | weatherDataObj.aqiDisplay = airData.aqiDisplay; 239 | weatherDataObj.aqiLevel = airData.level; 240 | weatherDataObj.aqiCategory = airData.category; 241 | weatherDataObj.aqiColor = airData.color; 242 | weatherDataObj.primaryPollutant = airData.primaryPollutant; 243 | } 244 | // 空气质量数据获取失败时静默处理,不在客户端显示错误 245 | } 246 | } catch (airError) { 247 | // 空气质量获取失败不影响天气显示,静默处理 248 | } 249 | } 250 | 251 | setWeatherData(weatherDataObj); 252 | } catch (err) { 253 | console.error('获取天气数据失败:', err); 254 | // 显示更友好的错误信息给用户 255 | const errorMessage = err instanceof Error ? err.message : '未知错误'; 256 | setError(errorMessage); 257 | } finally { 258 | setLoading(false); 259 | setIsRefreshing(false); 260 | if (city !== 'auto') { 261 | // 只有在手动输入城市时才关闭选择器 262 | setShowCitySelector(false); 263 | setCustomCity(''); 264 | } 265 | } 266 | }; 267 | 268 | const handleCitySelect = () => { 269 | if (customCity.trim()) { 270 | fetchWeatherData(customCity.trim()); 271 | } 272 | }; 273 | 274 | const handleRefreshLocation = () => { 275 | fetchWeatherData('auto'); 276 | }; 277 | 278 | const toggleCitySelector = (e: React.MouseEvent) => { 279 | e.stopPropagation(); 280 | setShowCitySelector(!showCitySelector); 281 | }; 282 | 283 | const getAqiCategoryChineseName = (category?: string) => { 284 | if (!category) return '良好'; 285 | return aqiCategoryMap[category] || category; 286 | }; 287 | 288 | // 5. 渲染城市选择器 289 | const renderCitySelector = () => { 290 | if (!mounted || !showCitySelector || !portalContainer) return null; 291 | 292 | // 计算弹窗位置 293 | const position = buttonRef.current?.getBoundingClientRect() || { left: 0, bottom: 0 }; 294 | 295 | return createPortal( 296 |
e.stopPropagation()} 304 | > 305 |
306 | 328 | 329 | {savedCity && ( 330 | 348 | )} 349 | 350 |
351 | 352 |
353 |
手动输入城市
354 |
355 | setCustomCity(e.target.value)} 360 | placeholder="输入城市名称" 361 | className="w-24 text-xs p-1 border border-border/40 rounded bg-background/80 focus:outline-none focus:ring-1 focus:ring-primary" 362 | onKeyDown={(e) => { 363 | if (e.key === 'Enter') { 364 | e.preventDefault(); 365 | handleCitySelect(); 366 | } 367 | }} 368 | /> 369 | 380 |
381 |
382 |
383 |
, 384 | portalContainer 385 | ); 386 | }; 387 | 388 | // 6. 条件渲染 389 | if (!mounted) { 390 | return ( 391 |
392 |
393 |
394 |
395 |

加载中...

396 |
397 |
398 | ); 399 | } 400 | 401 | if (loading && !isRefreshing) { 402 | return ( 403 | 409 |
410 |
411 |

获取天气信息...

412 |
413 |
414 | ); 415 | } 416 | 417 | if (error) { 418 | return ( 419 | 425 | {/* 背景装饰 - 主题感知 */} 426 |
427 | 428 |
429 |
430 |

获取失败

431 |

{currentCity}

432 |
433 |
434 |
435 | 446 | {renderCitySelector()} 447 |
448 |
449 | ⚠️ 450 |
451 |
452 |
453 | 454 |
455 |

{error}

456 | 462 |
463 |
464 | ); 465 | } 466 | 467 | // 7. 主渲染 468 | return ( 469 | 475 | {/* 背景装饰 - 主题感知 */} 476 |
477 | 478 |
479 |
480 |

{weatherData?.temperature}°

481 |
482 | {weatherData?.location} 483 |
484 | 495 | {renderCitySelector()} 496 |
497 |
498 |
499 |
500 | {weatherData?.icon && ( 501 | 502 | )} 503 |
504 |
505 | 506 |
507 |

{weatherData?.condition}

508 |
509 |

{weatherData?.tempMin}° ~ {weatherData?.tempMax}°

510 | 511 | {/* 改进空气质量显示 - 添加AQI数值 */} 512 | {weatherData?.aqi && ( 513 |
514 |
521 | {weatherData.aqiCategory} 522 |
523 | {weatherData.aqi} 524 |
525 | )} 526 |
527 |
528 | 529 | {/* 添加和风天气图标的CSS */} 530 | 550 |
551 | ); 552 | } -------------------------------------------------------------------------------- /src/components/widgets/index.ts: -------------------------------------------------------------------------------- 1 | // 小组件导出文件 2 | export { default as SimpleTime } from './SimpleTime'; 3 | export { default as AnalogClock } from './AnalogClock'; 4 | export { default as Weather } from './Weather'; 5 | export { default as IPInfo } from './IPInfo'; 6 | export { default as HotNews } from './HotNews'; -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | // 默认配置 2 | export const defaultConfig = { 3 | // 基础配置 4 | SITE_TITLE: 'Notion 导航站', 5 | SITE_DESCRIPTION: '使用 Notion 作为数据库的个人导航网站 - 为您提供高质量的网址导航服务', 6 | SITE_KEYWORDS: 'Notion导航,网址导航,个人导航,书签管理,在线工具,效率工具', 7 | SITE_FOOTER: '', 8 | // 社交媒体配置 9 | SOCIAL_GITHUB: '', 10 | SOCIAL_BLOG: '', 11 | SOCIAL_X: '', 12 | SOCIAL_JIKE: '', 13 | SOCIAL_WEIBO: '' 14 | } as const; 15 | 16 | // 环境变量配置 17 | export const envConfig = { 18 | // Notion配置 19 | NOTION_TOKEN: process.env.NOTION_TOKEN, 20 | NOTION_LINKS_DB_ID: process.env.NOTION_LINKS_DB_ID, 21 | NOTION_WEBSITE_CONFIG_ID: process.env.NOTION_WEBSITE_CONFIG_ID, 22 | NOTION_CATEGORIES_DB_ID: process.env.NOTION_CATEGORIES_DB_ID, 23 | // 分析和统计 24 | CLARITY_ID: process.env.NEXT_PUBLIC_CLARITY_ID ?? '', 25 | GA_ID: process.env.GA_ID ?? '', 26 | // 页面重新验证时间(秒) 27 | // REVALIDATE_TIME: parseInt(process.env.REVALIDATE_TIME ?? '3600', 10), 28 | } as const; 29 | 30 | // 配置类型 31 | export type Config = typeof defaultConfig & { 32 | // 添加可选的运行时配置项 33 | CLARITY_ID?: string; 34 | GA_ID?: string; 35 | // 主题配置 36 | THEME_NAME?: string; 37 | SHOW_THEME_SWITCHER?: string; 38 | }; 39 | 40 | // 合并配置 41 | export function mergeConfig(runtimeConfig: Partial = {}): Config { 42 | return { 43 | ...defaultConfig, 44 | ...runtimeConfig, 45 | CLARITY_ID: envConfig.CLARITY_ID, 46 | GA_ID: envConfig.GA_ID, 47 | }; 48 | } -------------------------------------------------------------------------------- /src/lib/category.ts: -------------------------------------------------------------------------------- 1 | // src/utils/category.ts 2 | import { Link } from '@/types/notion'; 3 | 4 | // 创建一个分类数据结构 5 | export interface CategoryData { 6 | name: string; 7 | count: number; 8 | subCategories: { 9 | [key: string]: number; // 子分类名称: 该子分类下的链接数量 10 | }; 11 | } 12 | 13 | export function organizeCategories(links: Link[]): Record { 14 | // 使用 reduce 来构建分类数据 15 | return links.reduce((acc, link) => { 16 | const category = link.category1; 17 | const subCategory = link.category2; 18 | 19 | // 如果这个主分类还不存在,创建它 20 | if (!acc[category]) { 21 | acc[category] = { 22 | name: category, 23 | count: 0, 24 | subCategories: {}, 25 | }; 26 | } 27 | 28 | // 增加主分类计数 29 | acc[category].count++; 30 | 31 | // 如果有子分类,处理子分类 32 | if (subCategory) { 33 | if (!acc[category].subCategories[subCategory]) { 34 | acc[category].subCategories[subCategory] = 0; 35 | } 36 | acc[category].subCategories[subCategory]++; 37 | } 38 | 39 | return acc; 40 | }, {} as Record); 41 | } -------------------------------------------------------------------------------- /src/lib/notion.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@notionhq/client"; 2 | import { 3 | PageObjectResponse, 4 | TitlePropertyItemObjectResponse, 5 | RichTextPropertyItemObjectResponse, 6 | FilesPropertyItemObjectResponse 7 | } from "@notionhq/client/build/src/api-endpoints"; 8 | import { WebsiteConfig } from "@/types/notion"; 9 | import { cache } from "react"; 10 | 11 | // 定义获取标题文本的辅助函数 12 | const getTitleText = (titleProperty?: TitlePropertyItemObjectResponse | null): string => { 13 | if (!titleProperty?.title || !Array.isArray(titleProperty.title)) return ''; 14 | return titleProperty.title[0]?.plain_text ?? ''; 15 | }; 16 | 17 | // 定义获取富文本内容的辅助函数 18 | const getRichText = (richTextProperty?: RichTextPropertyItemObjectResponse | null): string => { 19 | if (!richTextProperty?.rich_text || !Array.isArray(richTextProperty.rich_text)) return ''; 20 | return richTextProperty.rich_text[0]?.plain_text ?? ''; 21 | }; 22 | 23 | // 定义获取文件 URL 的辅助函数 24 | export const getFileUrl = (fileProperty?: FilesPropertyItemObjectResponse | null): string => { 25 | if (!fileProperty?.files || !Array.isArray(fileProperty.files) || !fileProperty.files[0]) return ''; 26 | const file = fileProperty.files[0]; 27 | 28 | // 处理外部文件 29 | if (file.type === 'external' && file.external) { 30 | return file.external.url; 31 | } 32 | // 处理内部文件 33 | if (file.type === 'file' && file.file) { 34 | return file.file.url; 35 | } 36 | return ''; 37 | }; 38 | 39 | // 定义 Notion 数据库的属性结构 40 | interface NotionProperties { 41 | Name: TitlePropertyItemObjectResponse; 42 | Value: RichTextPropertyItemObjectResponse; 43 | } 44 | 45 | type NotionPage = PageObjectResponse & { 46 | properties: NotionProperties; 47 | } 48 | 49 | import { envConfig } from '@/config'; 50 | 51 | export const notion = new Client({ 52 | auth: envConfig.NOTION_TOKEN 53 | }); 54 | 55 | // 添加获取图标URL的辅助函数 56 | const getIconUrl = (page: any): string => { 57 | // 优先使用自定义图标链接 58 | if (page.properties.iconlink?.url) { 59 | return page.properties.iconlink.url; 60 | } 61 | 62 | // 其次使用上图标文件 63 | if (page.properties.iconfile?.files?.[0]) { 64 | const file = page.properties.iconfile.files[0]; 65 | return file.type === 'external' ? file.external.url : file.file.url; 66 | } 67 | 68 | // 最后使用页面图标 69 | if (page.icon) { 70 | if (page.icon.type === 'emoji') { 71 | return page.icon.emoji; 72 | } 73 | if (page.icon.type === 'file') { 74 | return page.icon.file.url; 75 | } 76 | if (page.icon.type === 'external') { 77 | return page.icon.external.url; 78 | } 79 | } 80 | 81 | return ''; // 如果没有图标则返回空字符串 82 | }; 83 | 84 | // 获取网址链接 85 | export const getLinks = cache(async () => { 86 | const databaseId = envConfig.NOTION_LINKS_DB_ID!; 87 | const allLinks = []; 88 | let hasMore = true; 89 | let nextCursor: string | undefined; 90 | 91 | try { 92 | while (hasMore) { 93 | const response = await notion.databases.query({ 94 | database_id: databaseId, 95 | start_cursor: nextCursor, 96 | sorts: [ 97 | { 98 | property: 'category1', 99 | direction: 'ascending', 100 | }, 101 | { 102 | property: 'category2', 103 | direction: 'ascending', 104 | }, 105 | ], 106 | }); 107 | 108 | const links = response.results.map((page: any) => { 109 | // 处理 iconfile 110 | let iconfileUrl = ''; 111 | if (page.properties.iconfile?.files?.[0]) { 112 | const file = page.properties.iconfile.files[0]; 113 | if (file.type === 'external' && file.external) { 114 | iconfileUrl = file.external.url; 115 | } else if (file.type === 'file' && file.file) { 116 | iconfileUrl = file.file.url; 117 | } 118 | } 119 | 120 | return { 121 | id: page.id, 122 | name: page.properties.Name?.title[0]?.plain_text || '未命名', 123 | created: page.properties.Created?.created_time || '', 124 | desc: page.properties.desc?.rich_text[0]?.plain_text || '', 125 | url: page.properties.URL?.url || '#', 126 | category1: page.properties.category1?.select?.name || '未分类', 127 | category2: page.properties.category2?.select?.name || '默认', 128 | iconfile: iconfileUrl, 129 | iconlink: page.properties.iconlink?.url || '', 130 | tags: page.properties.Tags?.multi_select?.map((tag: any) => tag.name) || [], 131 | }; 132 | }); 133 | 134 | allLinks.push(...links); 135 | hasMore = response.has_more; 136 | nextCursor = response.next_cursor || undefined; 137 | } 138 | 139 | // 对链接进行排序:先按是否置顶,再按创建时间 140 | allLinks.sort((a, b) => { 141 | // 检查是否包含"力荐👍" 142 | const aIsTop = a.tags.includes('力荐👍'); 143 | const bIsTop = b.tags.includes('力荐👍'); 144 | 145 | // 如果置顶状态不同,置顶的排在前面 146 | if (aIsTop !== bIsTop) { 147 | return aIsTop ? -1 : 1; 148 | } 149 | 150 | // 如果置顶状态相同,按创建时间逆序排序 151 | return new Date(b.created).getTime() - new Date(a.created).getTime(); 152 | }); 153 | 154 | return allLinks; 155 | } catch (error) { 156 | console.error('Error fetching links:', error); 157 | return []; 158 | } 159 | }); 160 | 161 | // 获取网站配置 162 | export const getWebsiteConfig = cache(async () => { 163 | try { 164 | const response = await notion.databases.query({ 165 | database_id: envConfig.NOTION_WEBSITE_CONFIG_ID!, 166 | }); 167 | 168 | const configMap: WebsiteConfig = {}; 169 | 170 | response.results.forEach((page) => { 171 | const typedPage = page as NotionPage; 172 | const properties = typedPage.properties; 173 | 174 | // 使用辅助函数获取文本 175 | const name = getTitleText(properties.Name); 176 | const value = getRichText(properties.Value); 177 | 178 | if (name) { 179 | configMap[name.toUpperCase()] = value; 180 | } 181 | }); 182 | 183 | // 获取配置数据库页面的图标作为网站图标 184 | const database = await notion.databases.retrieve({ 185 | database_id: envConfig.NOTION_WEBSITE_CONFIG_ID! 186 | }) as any; 187 | let favicon = '/favicon.ico'; 188 | 189 | if (database.icon) { 190 | if (database.icon.type === 'emoji') { 191 | // 如果是 emoji,生成 data URL 192 | favicon = `data:image/svg+xml,${database.icon.emoji}`; 193 | } else if (database.icon.type === 'file') { 194 | favicon = database.icon.file.url; 195 | } else if (database.icon.type === 'external') { 196 | favicon = database.icon.external.url; 197 | } 198 | } 199 | 200 | // 返回基础配置 201 | // 将配置对象转换为 WebsiteConfig 类型 202 | const config: WebsiteConfig = { 203 | // 基础配置 204 | SITE_TITLE: configMap.SITE_TITLE ?? '我的导航', 205 | SITE_DESCRIPTION: configMap.SITE_DESCRIPTION ?? '个人导航网站', 206 | SITE_KEYWORDS: configMap.SITE_KEYWORDS ?? '导航,网址导航', 207 | SITE_AUTHOR: configMap.SITE_AUTHOR ?? '', 208 | SITE_FOOTER: configMap.SITE_FOOTER ?? '', 209 | SITE_FAVICON: favicon, 210 | // 主题配置 211 | THEME_NAME: configMap.THEME_NAME ?? 'simple', 212 | SHOW_THEME_SWITCHER: configMap.SHOW_THEME_SWITCHER ?? 'true', 213 | 214 | // 社交媒体配置 215 | SOCIAL_GITHUB: configMap.SOCIAL_GITHUB ?? '', 216 | SOCIAL_BLOG: configMap.SOCIAL_BLOG ?? '', 217 | SOCIAL_X: configMap.SOCIAL_X ?? '', 218 | SOCIAL_JIKE: configMap.SOCIAL_JIKE ?? '', 219 | SOCIAL_WEIBO: configMap.SOCIAL_WEIBO ?? '', 220 | // 分析和统计 221 | CLARITY_ID: configMap.CLARITY_ID ?? '', 222 | GA_ID: configMap.GA_ID ?? '', 223 | // 新增widgets配置 224 | WIDGET_CONFIG: configMap.WIDGET_CONFIG ?? '', 225 | }; 226 | 227 | return config; 228 | } catch (error) { 229 | console.error('获取网站配置失败:', error); 230 | throw new Error('获取网站配置失败'); 231 | } 232 | }); 233 | 234 | export const getCategories = cache(async () => { 235 | const databaseId = envConfig.NOTION_CATEGORIES_DB_ID; 236 | 237 | if (!databaseId) { 238 | return []; 239 | } 240 | 241 | try { 242 | const response = await notion.databases.query({ 243 | database_id: databaseId, 244 | filter: { 245 | property: 'Enabled', 246 | checkbox: { 247 | equals: true 248 | } 249 | }, 250 | sorts: [ 251 | { 252 | property: 'Order', 253 | direction: 'ascending', 254 | }, 255 | ], 256 | }); 257 | 258 | const categories = response.results.map((page: any) => ({ 259 | id: page.id, 260 | name: getTitleText(page.properties.Name), 261 | iconName: getRichText(page.properties.IconName), 262 | order: page.properties.Order?.number || 0, 263 | enabled: page.properties.Enabled?.checkbox || false, 264 | })); 265 | 266 | return categories.sort((a, b) => a.order - b.order); 267 | } catch (error) { 268 | return []; 269 | } 270 | }); -------------------------------------------------------------------------------- /src/lib/themes.ts: -------------------------------------------------------------------------------- 1 | // 从themes/index.ts导出所有主题相关功能 2 | // 这个文件是为了向后兼容,新代码应该直接从@/themes导入 3 | 4 | export { 5 | themes, 6 | defaultTheme, 7 | getAllThemes, 8 | } from '@/themes'; -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/themes/README.md: -------------------------------------------------------------------------------- 1 | # 自定义主题指南 2 | 3 | ## 概述 4 | 5 | 本项目支持通过编程方式自定义主题,开发者可以创建自己的主题文件并注册到系统中。用户只需在Notion中配置主题选择开关即可切换不同的主题。 6 | 7 | ## 主题文件结构 8 | 9 | 每个主题文件应该导出一个符合`ThemeConfig`接口的对象,包含以下结构: 10 | 11 | ```typescript 12 | export const myTheme: ThemeConfig = { 13 | name: 'my-theme', // 主题的唯一标识符 14 | displayName: '我的主题', // 显示在UI中的主题名称 15 | modes: ['light', 'dark'], // 支持的模式 16 | styles: { 17 | light: { /* 浅色模式样式 */ }, 18 | dark: { /* 深色模式样式 */ } 19 | } 20 | }; 21 | ``` 22 | 23 | ## 创建自定义主题 24 | 25 | 1. 在`src/themes`目录下创建一个新的TypeScript文件或目录,例如`my-theme.ts`或`my-theme/index.ts` 26 | 2. 参考现有主题文件(如`simple/index.ts`或`cyberpunk/index.ts`)的结构 27 | 3. 定义并导出你的主题配置对象 28 | 29 | ## 注册主题 30 | 31 | 在`src/themes/index.ts`文件中导入并注册你的主题: 32 | 33 | ```typescript 34 | import { myTheme } from './my-theme'; 35 | 36 | // 添加到themes对象中 37 | export const themes: Record = { 38 | simple: simpleTheme, 39 | cyberpunk: cyberpunkTheme, 40 | 'my-theme': myTheme, // 添加你的主题 41 | }; 42 | ``` 43 | 44 | 或者,你也可以使用`registerTheme`函数动态注册主题: 45 | 46 | ```typescript 47 | import { registerTheme } from '@/themes'; 48 | import { myTheme } from './my-theme'; 49 | 50 | registerTheme(myTheme); 51 | ``` 52 | 53 | ## 样式属性说明 54 | 55 | 主题样式对象包含多个类别的CSS变量,用于控制整个应用的外观: 56 | 57 | - **基础颜色**:定义主要颜色、背景、前景、强调色等 58 | - **字体相关**:字体族、字号、字重、行高等 59 | - **卡片样式**:卡片内边距、圆角、阴影等 60 | - **边框和圆角**:各种元素的边框样式和圆角大小 61 | - **间距**:不同尺寸的间距值 62 | - **动画和过渡**:过渡时间和缓动函数 63 | - **阴影**:不同级别的阴影效果 64 | 65 | 详细的属性列表可以参考`src/types/theme.ts`中的`ThemeStyles`接口定义。 66 | 67 | ## 主题模式 68 | 69 | 每个主题可以支持多种模式,最常见的是`light`(浅色)和`dark`(深色)模式。在`modes`数组中定义支持的模式,并在`styles`对象中为每种模式提供对应的样式配置。 70 | 71 | 用户可以通过主题切换器选择特定主题的特定模式,例如`simple-light`(简约浅色)或`cyberpunk-dark`(赛博朋克深色)。 72 | 73 | ## 示例 74 | 75 | 可以参考`src/themes/cyberpunk/index.ts`作为创建个性化主题的示例。该主题定义了独特的颜色方案、字体和视觉效果。 76 | 77 | ## 注意事项 78 | 79 | - 确保为每个主题提供完整的样式属性,避免缺失导致样式异常 80 | - 测试主题在不同模式(浅色/深色)下的表现 81 | - 主题名称必须唯一,避免与现有主题冲突 82 | - 如果主题需要特殊的CSS样式,可以创建一个目录结构并包含样式文件,参考`cyberpunk`主题的实现 -------------------------------------------------------------------------------- /src/themes/cyberpunk/index.ts: -------------------------------------------------------------------------------- 1 | import { type ThemeConfig } from '@/types/theme'; 2 | import './style.css'; 3 | 4 | // 赛博朋克主题配置 5 | export const cyberpunkTheme: ThemeConfig = { 6 | name: 'cyberpunk', 7 | displayName: '赛博朋克', 8 | modes: ['dark'], 9 | styles: { 10 | dark: { 11 | // 基础颜色 12 | primary: 'hsl(286, 100%, 65%)', // 赛博朋克紫 13 | 'primary-foreground': 'hsl(0, 0%, 100%)', 14 | background: 'hsl(232, 40%, 4%)', // 更深邃的背景 15 | 'background-foreground': 'hsl(0, 0%, 100%)', 16 | muted: 'hsl(232, 35%, 12%)', 17 | 'muted-foreground': 'hsl(232, 20%, 70%)', 18 | accent: 'hsl(180, 100%, 55%)', // 科技青 19 | 'accent-foreground': 'hsl(0, 0%, 100%)', 20 | card: 'hsl(232, 40%, 8%)', // 卡片背景 21 | 'card-foreground': 'hsl(0, 0%, 100%)', 22 | border: 'hsl(286, 100%, 65%)', // 主题色边框 23 | popover: 'hsl(232, 40%, 8%)', 24 | 'popover-foreground': 'hsl(0, 0%, 100%)', 25 | 26 | // 字体相关 27 | 'font-family': '"Rajdhani", "Orbitron", var(--font-geist-sans), system-ui, sans-serif', 28 | 'font-size-base': '1rem', 29 | 'font-size-sm': '0.875rem', 30 | 'font-size-lg': '1.25rem', 31 | 'font-weight-normal': '400', 32 | 'font-weight-medium': '600', 33 | 'font-weight-bold': '700', 34 | 'line-height': '1.3', 35 | 36 | // 卡片样式 37 | 'card-padding': '1.5rem', 38 | 'card-radius': '0', 39 | 'card-shadow': '0 0 20px hsla(286, 100%, 65%, 0.4), 0 0 10px hsla(180, 100%, 55%, 0.3)', 40 | 'card-hover-shadow': '0 0 40px hsla(286, 100%, 65%, 0.7), 0 0 20px hsla(180, 100%, 55%, 0.5), 0 0 60px hsla(286, 100%, 65%, 0.3)', 41 | 'card-border-width': '2px', 42 | 'card-border-style': 'solid', 43 | 44 | // 边框和圆角 45 | 'border-radius-sm': '0.125rem', 46 | 'border-radius-md': '0.25rem', 47 | 'border-radius-lg': '0.375rem', 48 | 'border-width': '2px', 49 | 'border-style': 'solid', 50 | 51 | // 间距 52 | 'spacing-xs': '0.5rem', 53 | 'spacing-sm': '0.75rem', 54 | 'spacing-md': '1.25rem', 55 | 'spacing-lg': '2rem', 56 | 'spacing-xl': '3rem', 57 | 58 | // 动画和过渡 59 | 'transition-duration': '150ms', 60 | 'transition-timing': 'cubic-bezier(0.16, 1, 0.3, 1)', 61 | 62 | // 阴影 63 | 'shadow-sm': '0 0 10px hsla(286, 100%, 65%, 0.4), 0 0 5px hsla(180, 100%, 55%, 0.2)', 64 | 'shadow-md': '0 0 15px hsla(286, 100%, 65%, 0.5), 0 0 8px hsla(180, 100%, 55%, 0.3)', 65 | 'shadow-lg': '0 0 25px hsla(286, 100%, 65%, 0.6), 0 0 12px hsla(180, 100%, 55%, 0.35)', 66 | 'shadow-hover': '0 0 35px hsla(286, 100%, 65%, 0.8), 0 0 20px hsla(180, 100%, 55%, 0.5), 0 0 60px hsla(286, 100%, 65%, 0.4)' 67 | } 68 | } 69 | }; -------------------------------------------------------------------------------- /src/themes/cyberpunk/style.css: -------------------------------------------------------------------------------- 1 | /* 赛博朋克主题样式 - 同步原项目风格 */ 2 | 3 | /* 导入字体 */ 4 | @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&family=Rajdhani:wght@300;400;500;600;700&display=swap'); 5 | 6 | /* 全局变量 */ 7 | [data-theme="cyberpunk-dark"] { 8 | --text-glow: 0 0 10px currentColor; 9 | --card-glow: 0 0 20px hsla(286, 100%, 65%, 0.4), 0 0 10px hsla(180, 100%, 55%, 0.3); 10 | --card-glow-hover: 0 0 40px hsla(286, 100%, 65%, 0.7), 0 0 20px hsla(180, 100%, 55%, 0.5), 0 0 60px hsla(286, 100%, 65%, 0.3); 11 | font-family: 'Rajdhani', 'Noto Sans SC', sans-serif; 12 | } 13 | 14 | /* 标题样式 */ 15 | [data-theme="cyberpunk-dark"] h1, 16 | [data-theme="cyberpunk-dark"] h2, 17 | [data-theme="cyberpunk-dark"] h3, 18 | [data-theme="cyberpunk-dark"] h4, 19 | [data-theme="cyberpunk-dark"] h5, 20 | [data-theme="cyberpunk-dark"] h6 { 21 | font-family: 'Orbitron', 'Rajdhani', 'Noto Sans SC', sans-serif; 22 | text-transform: uppercase; 23 | letter-spacing: 0.1em; 24 | font-weight: 700; 25 | } 26 | 27 | [data-theme="cyberpunk-dark"] h1 { font-size: 2.25rem; } 28 | [data-theme="cyberpunk-dark"] h2 { font-size: 1.75rem; } 29 | [data-theme="cyberpunk-dark"] h3 { font-size: 1.25rem; } 30 | 31 | /* 卡片样式 */ 32 | [data-theme="cyberpunk-dark"] .group { 33 | position: relative; 34 | background: linear-gradient(135deg, hsla(232, 40%, 8%, 0.8) 0%, hsla(232, 40%, 4%, 0.9) 100%); 35 | border: 2px solid transparent; 36 | border-radius: 0; 37 | overflow: hidden; 38 | backdrop-filter: blur(10px); 39 | -webkit-backdrop-filter: blur(10px); 40 | } 41 | 42 | [data-theme="cyberpunk-dark"] .group::before { 43 | content: ''; 44 | position: absolute; 45 | inset: 0; 46 | padding: 2px; 47 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 48 | -webkit-mask: 49 | linear-gradient(#fff 0 0) content-box, 50 | linear-gradient(#fff 0 0); 51 | -webkit-mask-composite: xor; 52 | mask-composite: exclude; 53 | pointer-events: none; 54 | } 55 | 56 | [data-theme="cyberpunk-dark"] .group:hover { 57 | transform: translateY(-2px); 58 | box-shadow: var(--card-glow-hover); 59 | } 60 | 61 | /* 按钮样式 */ 62 | [data-theme="cyberpunk-dark"] button { 63 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 64 | border: none; 65 | color: hsl(0, 0%, 100%); 66 | text-shadow: var(--text-glow); 67 | font-family: 'Rajdhani', sans-serif; 68 | font-weight: 600; 69 | letter-spacing: 0.05em; 70 | transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); 71 | } 72 | 73 | [data-theme="cyberpunk-dark"] button:hover { 74 | box-shadow: 0 0 20px currentColor; 75 | transform: translateY(-1px); 76 | } 77 | 78 | /* 链接样式 */ 79 | [data-theme="cyberpunk-dark"] a { 80 | color: hsl(180, 100%, 55%); 81 | text-decoration: none; 82 | transition: all 0.3s ease; 83 | } 84 | 85 | [data-theme="cyberpunk-dark"] a:hover { 86 | color: hsl(286, 100%, 65%); 87 | text-shadow: var(--text-glow); 88 | } 89 | 90 | /* 导航样式 */ 91 | [data-theme="cyberpunk-dark"] nav { 92 | background: linear-gradient(to bottom, hsla(232, 40%, 8%, 0.8), hsla(232, 40%, 4%, 0.9)); 93 | backdrop-filter: blur(10px); 94 | -webkit-backdrop-filter: blur(10px); 95 | border-color: hsl(286, 100%, 65%); 96 | } 97 | 98 | /* 滚动条样式 */ 99 | [data-theme="cyberpunk-dark"] ::-webkit-scrollbar { 100 | width: 8px; 101 | height: 8px; 102 | } 103 | 104 | [data-theme="cyberpunk-dark"] ::-webkit-scrollbar-track { 105 | background: hsla(232, 40%, 4%, 0.5); 106 | } 107 | 108 | [data-theme="cyberpunk-dark"] ::-webkit-scrollbar-thumb { 109 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 110 | border-radius: 4px; 111 | } 112 | 113 | [data-theme="cyberpunk-dark"] ::-webkit-scrollbar-thumb:hover { 114 | background: linear-gradient(135deg, hsl(286, 100%, 75%), hsl(180, 100%, 65%)); 115 | } 116 | 117 | /* 组件特定样式 */ 118 | [data-theme="cyberpunk-dark"] body .widget-card { 119 | border: 2px solid transparent; 120 | background: linear-gradient(135deg, hsla(232, 40%, 8%, 0.8) 0%, hsla(232, 40%, 4%, 0.9) 100%); 121 | background-clip: padding-box; 122 | position: relative; 123 | color: #fff; 124 | font-family: 'Rajdhani', 'Noto Sans SC', sans-serif; 125 | font-size: 1.08rem; 126 | padding: 1.5rem 1.25rem; 127 | box-shadow: 0 0 24px 2px hsla(286, 100%, 65%, 0.25), 0 0 8px 2px hsla(180, 100%, 55%, 0.15); 128 | } 129 | 130 | [data-theme="cyberpunk-dark"] body .widget-card::before { 131 | content: ''; 132 | position: absolute; 133 | inset: 0; 134 | border-radius: 0; 135 | padding: 2px; 136 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 137 | z-index: 1; 138 | pointer-events: none; 139 | mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 140 | mask-composite: exclude; 141 | } 142 | 143 | [data-theme="cyberpunk-dark"] body .widget-card > * { 144 | position: relative; 145 | z-index: 2; 146 | } 147 | 148 | [data-theme="cyberpunk-dark"] body .widget-card, 149 | [data-theme="cyberpunk-dark"] body .widget-card * { 150 | font-family: 'Rajdhani', 'Noto Sans SC', sans-serif; 151 | font-weight: 500; 152 | letter-spacing: 0.02em; 153 | } 154 | 155 | [data-theme="cyberpunk-dark"] body .widget-card h1, 156 | [data-theme="cyberpunk-dark"] body .widget-card h2, 157 | [data-theme="cyberpunk-dark"] body .widget-card h3 { 158 | font-family: 'Orbitron', 'Rajdhani', 'Noto Sans SC', sans-serif; 159 | font-weight: 700; 160 | text-transform: uppercase; 161 | letter-spacing: 0.1em; 162 | } 163 | 164 | [data-theme="cyberpunk-dark"] body button { 165 | font-family: 'Rajdhani', sans-serif; 166 | font-weight: 600; 167 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 168 | color: #fff; 169 | letter-spacing: 0.05em; 170 | border-radius: 0; 171 | box-shadow: 0 0 8px hsla(286, 100%, 65%, 0.3); 172 | } 173 | 174 | [data-theme="cyberpunk-dark"] body button:hover { 175 | box-shadow: 0 0 20px currentColor; 176 | transform: translateY(-1px); 177 | } 178 | 179 | /* 导航样式 */ 180 | [data-theme="cyberpunk-dark"] .platform-tab { 181 | background: transparent; 182 | border: 2px solid hsl(286, 100%, 65%); 183 | color: #fff; 184 | border-radius: 0 !important; 185 | } 186 | 187 | [data-theme="cyberpunk-dark"] .platform-tab:hover { 188 | background: hsla(286, 100%, 65%, 0.2); 189 | border-color: hsl(180, 100%, 55%); 190 | } 191 | 192 | [data-theme="cyberpunk-dark"] .platform-tab-active { 193 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 194 | color: #fff; 195 | text-shadow: 0 0 10px currentColor; 196 | border-radius: 0 !important; 197 | } 198 | 199 | [data-theme="cyberpunk-dark"] .hot-news-item { 200 | border-left: 2px solid transparent; 201 | transition: all 0.3s ease; 202 | border-radius: 0 !important; 203 | } 204 | 205 | [data-theme="cyberpunk-dark"] .hot-news-item:hover { 206 | border-left-color: hsl(286, 100%, 65%); 207 | background: linear-gradient(90deg, hsla(286, 100%, 65%, 0.2), transparent); 208 | } 209 | 210 | [data-theme="cyberpunk-dark"] .hot-news-index { 211 | font-family: 'Orbitron', monospace; 212 | color: hsl(286, 100%, 65%); 213 | text-shadow: 0 0 10px currentColor; 214 | } 215 | 216 | [data-theme="cyberpunk-dark"] .hot-news-title { 217 | color: #fff; 218 | transition: all 0.3s ease; 219 | } 220 | 221 | [data-theme="cyberpunk-dark"] .hot-news-item:hover .hot-news-title { 222 | color: hsl(180, 100%, 55%); 223 | text-shadow: 0 0 10px currentColor; 224 | } 225 | 226 | [data-theme="cyberpunk-dark"] .hot-news-views { 227 | color: hsl(232, 20%, 70%); 228 | } 229 | 230 | [data-theme="cyberpunk-dark"] .hot-news-icon { 231 | color: hsl(180, 100%, 55%); 232 | } -------------------------------------------------------------------------------- /src/themes/index.ts: -------------------------------------------------------------------------------- 1 | import './theme.css' 2 | import { simpleTheme } from './simple'; 3 | import { cyberpunkTheme } from './cyberpunk'; 4 | 5 | export interface Theme { 6 | name: string 7 | displayName: string 8 | mode: 'light' | 'dark' 9 | } 10 | 11 | export const themes: Theme[] = [ 12 | { 13 | name: 'simple-light', 14 | displayName: '简约浅色', 15 | mode: 'light' 16 | }, 17 | { 18 | name: 'simple-dark', 19 | displayName: '简约深色', 20 | mode: 'dark' 21 | }, 22 | { 23 | name: 'cyberpunk-dark', 24 | displayName: '赛博朋克', 25 | mode: 'dark' 26 | } 27 | ] 28 | 29 | export function getAllThemes(): Theme[] { 30 | return themes 31 | } 32 | 33 | export function getThemeMode(themeName: string): 'light' | 'dark' { 34 | const theme = themes.find(t => t.name === themeName) 35 | return theme?.mode || 'light' 36 | } 37 | 38 | // 应用主题样式 39 | export function applyTheme(themeName: string) { 40 | // 确保主题名称有效 41 | if (!themes.find(t => t.name === themeName)) { 42 | themeName = 'simple-light' 43 | } 44 | 45 | document.documentElement.setAttribute('data-theme', themeName) 46 | // 保存到 localStorage 47 | localStorage.setItem('theme', themeName) 48 | // 触发主题变化事件 49 | window.dispatchEvent(new Event('themeChange')) 50 | } 51 | 52 | export { simpleTheme } from './simple'; 53 | export { cyberpunkTheme } from './cyberpunk'; 54 | 55 | export const defaultTheme = simpleTheme; -------------------------------------------------------------------------------- /src/themes/simple/base.css: -------------------------------------------------------------------------------- 1 | /* 简约主题基础样式 - 整合自原base.css */ 2 | 3 | :root { 4 | /* 基础变量 */ 5 | --radius: var(--border-radius-lg); 6 | --radius-sm: var(--border-radius-sm); 7 | --radius-md: var(--border-radius-md); 8 | --radius-lg: var(--border-radius-lg); 9 | 10 | /* 字体变量 */ 11 | --font-family: var(--font-family); 12 | --font-size: var(--font-size-base); 13 | --font-size-sm: var(--font-size-sm); 14 | --font-size-lg: var(--font-size-lg); 15 | --font-weight: var(--font-weight-normal); 16 | --font-weight-medium: var(--font-weight-medium); 17 | --font-weight-bold: var(--font-weight-bold); 18 | 19 | /* 间距变量 */ 20 | --spacing-xs: var(--spacing-xs); 21 | --spacing-sm: var(--spacing-sm); 22 | --spacing-md: var(--spacing-md); 23 | --spacing-lg: var(--spacing-lg); 24 | --spacing-xl: var(--spacing-xl); 25 | 26 | /* 阴影变量 */ 27 | --shadow-sm: var(--shadow-sm); 28 | --shadow-md: var(--shadow-md); 29 | --shadow-lg: var(--shadow-lg); 30 | --shadow-hover: var(--shadow-hover); 31 | } 32 | 33 | /* 主题基础样式 */ 34 | [data-theme] { 35 | background-color: var(--background); 36 | color: var(--background-foreground); 37 | font-family: var(--font-family); 38 | transition: background-color 0.3s ease, color 0.3s ease; 39 | } 40 | 41 | /* 卡片基础样式 */ 42 | .group { 43 | background-color: var(--card); 44 | color: var(--card-foreground); 45 | padding: var(--card-padding, 1.25rem); 46 | border-radius: var(--card-radius, 0.75rem); 47 | box-shadow: var(--card-shadow); 48 | transition: all var(--transition-duration, 200ms) var(--transition-timing, ease-out); 49 | } 50 | 51 | /* 链接样式 */ 52 | a { 53 | color: var(--primary); 54 | text-decoration: none; 55 | transition: color 0.2s ease; 56 | } 57 | 58 | a:hover { 59 | color: var(--primary-foreground); 60 | } 61 | 62 | /* 按钮基础样式 */ 63 | button { 64 | background-color: var(--primary); 65 | color: var(--primary-foreground); 66 | border: none; 67 | border-radius: var(--radius-sm); 68 | padding: 0.5rem 1rem; 69 | cursor: pointer; 70 | transition: all 0.2s ease; 71 | } 72 | 73 | button:hover { 74 | opacity: 0.9; 75 | } 76 | 77 | /* 输入框基础样式 */ 78 | input, textarea, select { 79 | background-color: var(--background); 80 | color: var(--background-foreground); 81 | border: var(--border-width) var(--border-style) var(--border); 82 | border-radius: var(--radius-sm); 83 | padding: 0.5rem; 84 | } 85 | 86 | /* 标签基础样式 */ 87 | .tag { 88 | display: inline-block; 89 | padding: 0.25rem 0.5rem; 90 | margin-right: 0.5rem; 91 | margin-bottom: 0.5rem; 92 | background-color: var(--muted); 93 | color: var(--muted-foreground); 94 | border-radius: var(--radius-sm); 95 | font-size: var(--font-size-sm); 96 | } -------------------------------------------------------------------------------- /src/themes/simple/index.ts: -------------------------------------------------------------------------------- 1 | import { type ThemeConfig } from '@/types/theme'; 2 | import './style.css'; 3 | 4 | // 简约主题配置 5 | export const simpleTheme: ThemeConfig = { 6 | name: 'simple', 7 | displayName: '简约', 8 | modes: ['light', 'dark'], 9 | styles: { 10 | light: { 11 | // 基础颜色 12 | primary: 'hsl(222.2 47.4% 11.2%)', 13 | 'primary-foreground': 'hsl(210 40% 98%)', 14 | background: 'hsl(0 0% 100%)', 15 | 'background-foreground': 'hsl(222.2 47.4% 11.2%)', 16 | muted: 'hsl(210 40% 96.1%)', 17 | 'muted-foreground': 'hsl(215.4 16.3% 46.9%)', 18 | accent: 'hsl(210 40% 96.1%)', 19 | 'accent-foreground': 'hsl(222.2 47.4% 11.2%)', 20 | card: 'hsl(0 0% 100%)', 21 | 'card-foreground': 'hsl(222.2 47.4% 11.2%)', 22 | border: 'hsl(214.3 31.8% 91.4%)', 23 | popover: 'hsl(0 0% 100%)', 24 | 'popover-foreground': 'hsl(222.2 47.4% 11.2%)', 25 | 26 | // 字体相关 27 | 'font-family': 'var(--font-geist-sans), system-ui, sans-serif', 28 | 'font-size-base': '1rem', 29 | 'font-size-sm': '0.875rem', 30 | 'font-size-lg': '1.125rem', 31 | 'font-weight-normal': '400', 32 | 'font-weight-medium': '500', 33 | 'font-weight-bold': '700', 34 | 'line-height': '1.5', 35 | 36 | // 卡片样式 37 | 'card-padding': '1.25rem', 38 | 'card-radius': '0.75rem', 39 | 'card-shadow': '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)', 40 | 'card-hover-shadow': '0 4px 12px rgba(0,0,0,0.1)', 41 | 'card-border-width': '1px', 42 | 'card-border-style': 'solid', 43 | 44 | // 边框和圆角 45 | 'border-radius-sm': '0.375rem', 46 | 'border-radius-md': '0.5rem', 47 | 'border-radius-lg': '0.75rem', 48 | 'border-width': '1px', 49 | 'border-style': 'solid', 50 | 51 | // 间距 52 | 'spacing-xs': '0.5rem', 53 | 'spacing-sm': '0.75rem', 54 | 'spacing-md': '1rem', 55 | 'spacing-lg': '1.5rem', 56 | 'spacing-xl': '2rem', 57 | 58 | // 动画和过渡 59 | 'transition-duration': '200ms', 60 | 'transition-timing': 'ease-out', 61 | 62 | // 阴影 63 | 'shadow-sm': '0 1px 2px rgba(0,0,0,0.05)', 64 | 'shadow-md': '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)', 65 | 'shadow-lg': '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)', 66 | 'shadow-hover': '0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04)' 67 | }, 68 | dark: { 69 | // 基础颜色 70 | primary: 'hsl(210 40% 98%)', 71 | 'primary-foreground': 'hsl(222.2 47.4% 11.2%)', 72 | background: 'hsl(222.2 84% 4.9%)', 73 | 'background-foreground': 'hsl(210 40% 98%)', 74 | muted: 'hsl(217.2 32.6% 17.5%)', 75 | 'muted-foreground': 'hsl(215 20.2% 65.1%)', 76 | accent: 'hsl(217.2 32.6% 17.5%)', 77 | 'accent-foreground': 'hsl(210 40% 98%)', 78 | card: 'hsl(222.2 84% 4.9%)', 79 | 'card-foreground': 'hsl(210 40% 98%)', 80 | border: 'hsl(217.2 32.6% 17.5%)', 81 | popover: 'hsl(222.2 84% 4.9%)', 82 | 'popover-foreground': 'hsl(210 40% 98%)', 83 | 84 | // 字体相关 85 | 'font-family': 'var(--font-geist-sans), system-ui, sans-serif', 86 | 'font-size-base': '1rem', 87 | 'font-size-sm': '0.875rem', 88 | 'font-size-lg': '1.125rem', 89 | 'font-weight-normal': '400', 90 | 'font-weight-medium': '500', 91 | 'font-weight-bold': '700', 92 | 'line-height': '1.5', 93 | 94 | // 卡片样式 95 | 'card-padding': '1.25rem', 96 | 'card-radius': '0.75rem', 97 | 'card-shadow': '0 1px 3px rgba(255,255,255,0.1), 0 1px 2px rgba(255,255,255,0.05)', 98 | 'card-hover-shadow': '0 4px 12px rgba(255,255,255,0.2)', 99 | 'card-border-width': '1px', 100 | 'card-border-style': 'solid', 101 | 102 | // 边框和圆角 103 | 'border-radius-sm': '0.375rem', 104 | 'border-radius-md': '0.5rem', 105 | 'border-radius-lg': '0.75rem', 106 | 'border-width': '1px', 107 | 'border-style': 'solid', 108 | 109 | // 间距 110 | 'spacing-xs': '0.5rem', 111 | 'spacing-sm': '0.75rem', 112 | 'spacing-md': '1rem', 113 | 'spacing-lg': '1.5rem', 114 | 'spacing-xl': '2rem', 115 | 116 | // 动画和过渡 117 | 'transition-duration': '200ms', 118 | 'transition-timing': 'ease-out', 119 | 120 | // 阴影 121 | 'shadow-sm': '0 1px 2px rgba(255,255,255,0.05)', 122 | 'shadow-md': '0 4px 6px -1px rgba(255,255,255,0.1), 0 2px 4px -1px rgba(255,255,255,0.06)', 123 | 'shadow-lg': '0 10px 15px -3px rgba(255,255,255,0.1), 0 4px 6px -2px rgba(255,255,255,0.05)', 124 | 'shadow-hover': '0 20px 25px -5px rgba(255,255,255,0.1), 0 10px 10px -5px rgba(255,255,255,0.04)' 125 | } 126 | } 127 | }; -------------------------------------------------------------------------------- /src/themes/simple/style.css: -------------------------------------------------------------------------------- 1 | /* 简约主题样式 */ 2 | 3 | /* 导入思源黑体 */ 4 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap'); 5 | 6 | /* 浅色主题基础样式 */ 7 | [data-theme="simple-light"] { 8 | background-color: hsl(0 0% 100%); 9 | color: hsl(222.2 47.4% 11.2%); 10 | font-family: 'Noto Sans SC', var(--font-family); 11 | } 12 | 13 | /* 深色主题基础样式 */ 14 | [data-theme="simple-dark"] { 15 | background-color: hsl(222.2 84% 4.9%); 16 | color: hsl(210 40% 98%); 17 | font-family: 'Noto Sans SC', var(--font-family); 18 | transition: all 0.2s ease-out; 19 | border-radius: var(--radius); 20 | box-shadow: none; 21 | border: var(--border-width) var(--border-style) var(--border); 22 | transition: box-shadow 0.2s cubic-bezier(0.16,1,0.3,1), border-color 0.2s cubic-bezier(0.16,1,0.3,1), transform 0.2s cubic-bezier(0.16,1,0.3,1); 23 | overflow: hidden; 24 | } 25 | 26 | /* 卡片悬浮效果 */ 27 | [data-theme^="simple"] .group:hover { 28 | box-shadow: var(--shadow-md); 29 | border-color: transparent; 30 | transform: translateY(-1px); 31 | z-index: 2; 32 | } 33 | 34 | /* 标题文本效果 */ 35 | [data-theme^="simple"] .group h3 { 36 | font-weight: var(--font-weight-medium); 37 | color: var(--card-foreground); 38 | } 39 | 40 | /* 描述文本效果 */ 41 | [data-theme^="simple"] .group p { 42 | color: var(--card-foreground); 43 | } 44 | 45 | /* 图标容器效果 */ 46 | [data-theme^="simple"] .group .icon-container { 47 | border-radius: 9999px; 48 | background: var(--muted); 49 | } 50 | 51 | /* 标签效果 */ 52 | [data-theme^="simple"] .group .tag { 53 | border-radius: var(--radius-sm); 54 | background: var(--muted); 55 | font-size: var(--font-size-sm); 56 | color: var(--muted-foreground); 57 | } 58 | 59 | /* 导航组件样式 */ 60 | [data-theme^="simple"] nav { 61 | font-family: 'Noto Sans SC', var(--font-family); 62 | background: var(--background); 63 | border-color: var(--border); 64 | } 65 | 66 | /* 确保导航中的文字颜色正确 */ 67 | /* Removed: [data-theme^="simple"] nav span, [data-theme^="simple"] nav button { color: var(--foreground); } */ 68 | 69 | /* 导航按钮激活状态 */ 70 | [data-theme="simple-dark"] nav button.bg-primary { 71 | background-color: var(--primary); 72 | color: var(--primary-foreground) !important; /* Ensure dark text color for simple-dark active button */ 73 | } 74 | 75 | /* 导航按钮悬浮效果 */ 76 | [data-theme^="simple"] nav button:hover { 77 | background-color: var(--muted); 78 | } 79 | 80 | /* Add explicit color for simple-dark mobile nav text */ 81 | [data-theme="simple-dark"] nav span, 82 | [data-theme="simple-dark"] nav button { 83 | color: hsl(210 40% 98%) !important; /* Use a clear white */ 84 | } 85 | 86 | /* 组件样式 */ 87 | [data-theme^="simple"] .widget-card { 88 | background-color: var(--card); 89 | color: var(--card-foreground); 90 | border-radius: var(--radius); 91 | border: var(--border-width) var(--border-style) var(--border); 92 | box-shadow: var(--shadow-sm); 93 | /* transition: all var(--transition-duration) var(--transition-timing); */ 94 | } 95 | 96 | [data-theme^="simple"] .widget-card:hover { 97 | box-shadow: var(--shadow-hover); 98 | /* 移除transform,防止抖动 */ 99 | /* transform: translateY(-1px); */ 100 | } 101 | 102 | [data-theme^="simple"] .platform-tab { 103 | background: transparent; 104 | color: var(--muted-foreground); 105 | transition: all var(--transition-duration) var(--transition-timing); 106 | } 107 | 108 | [data-theme^="simple"] .platform-tab:hover { 109 | background: var(--muted); 110 | color: var(--foreground); 111 | } 112 | 113 | [data-theme^="simple"] .platform-tab-active { 114 | background: var(--primary); 115 | color: var(--primary-foreground); 116 | font-weight: var(--font-weight-medium); 117 | } 118 | 119 | [data-theme^="simple"] .hot-news-item { 120 | background-color: var(--card); 121 | color: var(--card-foreground); 122 | border-radius: var(--radius); 123 | border: var(--border-width) var(--border-style) var(--border); 124 | box-shadow: var(--shadow-sm); 125 | transition: all var(--transition-duration) var(--transition-timing); 126 | } 127 | 128 | [data-theme^="simple"] .hot-news-item:hover { 129 | box-shadow: var(--shadow-hover); 130 | transform: translateY(-1px); 131 | border-left-color: var(--primary); 132 | background: var(--accent); 133 | } 134 | 135 | [data-theme^="simple"] .hot-news-index { 136 | color: var(--muted-foreground); 137 | } 138 | 139 | [data-theme^="simple"] .hot-news-title { 140 | color: var(--card-foreground); 141 | transition: color var(--transition-duration) var(--transition-timing); 142 | } 143 | 144 | [data-theme^="simple"] .hot-news-item:hover .hot-news-title { 145 | color: var(--primary); 146 | } 147 | 148 | [data-theme^="simple"] .hot-news-views { 149 | color: var(--muted-foreground); 150 | } 151 | 152 | [data-theme^="simple"] .hot-news-icon { 153 | color: var(--muted-foreground); 154 | } 155 | 156 | /* 如果需要覆盖某些默认样式,可以在这里添加 */ 157 | .widget-card { 158 | @apply rounded-xl; 159 | } 160 | 161 | .platform-tab { 162 | @apply rounded-full; 163 | } -------------------------------------------------------------------------------- /src/themes/theme.css: -------------------------------------------------------------------------------- 1 | /* 导入字体 */ 2 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap'); 3 | 4 | /* 基础变量 */ 5 | :root { 6 | --font-sans: 'Noto Sans SC', system-ui, -apple-system, sans-serif; 7 | --radius-sm: 0.3rem; 8 | --radius-md: 0.5rem; 9 | --radius-lg: 0.8rem; 10 | --transition-all: all 0.2s ease-out; 11 | } 12 | 13 | /* 简约浅色主题 */ 14 | [data-theme="simple-light"] { 15 | --background: hsl(0 0% 100%); 16 | --foreground: hsl(222.2 47.4% 11.2%); 17 | 18 | --muted: hsl(210 40% 96.1%); 19 | --muted-foreground: hsl(215.4 16.3% 46.9%); 20 | 21 | --popover: hsl(0 0% 100%); 22 | --popover-foreground: hsl(222.2 47.4% 11.2%); 23 | 24 | --card: hsl(0 0% 100%); 25 | --card-foreground: hsl(222.2 47.4% 11.2%); 26 | 27 | --border: hsl(214.3 31.8% 91.4%); 28 | --input: hsl(214.3 31.8% 91.4%); 29 | 30 | --primary: hsl(222.2 47.4% 11.2%); 31 | --primary-foreground: hsl(210 40% 98%); 32 | 33 | --secondary: hsl(210 40% 96.1%); 34 | --secondary-foreground: hsl(222.2 47.4% 11.2%); 35 | 36 | --accent: hsl(210 40% 96.1%); 37 | --accent-foreground: hsl(222.2 47.4% 11.2%); 38 | 39 | --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); 40 | --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 41 | --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 42 | } 43 | 44 | /* 简约深色主题 */ 45 | [data-theme="simple-dark"] { 46 | --background: hsl(222.2 84% 4.9%); 47 | --foreground: hsl(210 40% 98%); 48 | 49 | --muted: hsl(217.2 32.6% 17.5%); 50 | --muted-foreground: hsl(215 20.2% 65.1%); 51 | 52 | --popover: hsl(222.2 84% 4.9%); 53 | --popover-foreground: hsl(210 40% 98%); 54 | 55 | --card: hsl(222.2 84% 4.9%); 56 | --card-foreground: hsl(210 40% 98%); 57 | 58 | --border: hsl(217.2 32.6% 17.5%); 59 | --input: hsl(217.2 32.6% 17.5%); 60 | 61 | --primary: hsl(210 40% 98%); 62 | --primary-foreground: hsl(222.2 47.4% 11.2%); 63 | 64 | --secondary: hsl(217.2 32.6% 17.5%); 65 | --secondary-foreground: hsl(210 40% 98%); 66 | 67 | --accent: hsl(217.2 32.6% 17.5%); 68 | --accent-foreground: hsl(210 40% 98%); 69 | 70 | --shadow-sm: 0 1px 2px 0 rgb(255 255 255 / 0.05); 71 | --shadow-md: 0 4px 6px -1px rgb(255 255 255 / 0.1), 0 2px 4px -2px rgb(255 255 255 / 0.1); 72 | --shadow-lg: 0 10px 15px -3px rgb(255 255 255 / 0.1), 0 4px 6px -4px rgb(255 255 255 / 0.1); 73 | } 74 | 75 | /* 赛博朋克主题 */ 76 | [data-theme="cyberpunk-dark"] { 77 | --background: hsl(232, 40%, 4%); 78 | --foreground: hsl(0, 0%, 100%); 79 | 80 | --muted: hsla(232, 40%, 8%, 0.8); 81 | --muted-foreground: hsl(232, 20%, 70%); 82 | 83 | --popover: hsla(232, 40%, 8%, 0.8); 84 | --popover-foreground: hsl(0, 0%, 100%); 85 | 86 | --card: hsla(232, 40%, 8%, 0.8); 87 | --card-foreground: hsl(0, 0%, 100%); 88 | 89 | --border: hsl(286, 100%, 65%); 90 | --input: hsla(232, 40%, 8%, 0.8); 91 | 92 | --primary: hsl(286, 100%, 65%); 93 | --primary-foreground: hsl(0, 0%, 100%); 94 | 95 | --secondary: hsl(180, 100%, 55%); 96 | --secondary-foreground: hsl(0, 0%, 100%); 97 | 98 | --accent: hsla(286, 100%, 65%, 0.2); 99 | --accent-foreground: hsl(0, 0%, 100%); 100 | 101 | --shadow-sm: 0 0 10px hsla(286, 100%, 65%, 0.2); 102 | --shadow-md: 0 0 20px hsla(286, 100%, 65%, 0.4), 0 0 10px hsla(180, 100%, 55%, 0.3); 103 | --shadow-lg: 0 0 40px hsla(286, 100%, 65%, 0.7), 0 0 20px hsla(180, 100%, 55%, 0.5); 104 | font-family: 'Rajdhani', 'Noto Sans SC', sans-serif; 105 | font-size: 1.08rem; 106 | font-weight: 500; 107 | letter-spacing: 0.02em; 108 | } 109 | 110 | [data-theme="cyberpunk-dark"] h1, 111 | [data-theme="cyberpunk-dark"] h2, 112 | [data-theme="cyberpunk-dark"] h3, 113 | [data-theme="cyberpunk-dark"] h4, 114 | [data-theme="cyberpunk-dark"] h5, 115 | [data-theme="cyberpunk-dark"] h6 { 116 | font-family: 'Orbitron', 'Rajdhani', 'Noto Sans SC', sans-serif; 117 | text-transform: uppercase; 118 | font-weight: 700; 119 | letter-spacing: 0.1em; 120 | margin-bottom: 0.5em; 121 | } 122 | 123 | [data-theme="cyberpunk-dark"] .widget-card, 124 | [data-theme="cyberpunk-dark"] .group, 125 | [data-theme="cyberpunk-dark"] .platform-tab, 126 | [data-theme="cyberpunk-dark"] .hot-news-item { 127 | font-family: 'Rajdhani', 'Noto Sans SC', sans-serif; 128 | font-size: 1.08rem; 129 | font-weight: 500; 130 | letter-spacing: 0.02em; 131 | } 132 | 133 | [data-theme="cyberpunk-dark"] .widget-card h1, 134 | [data-theme="cyberpunk-dark"] .widget-card h2, 135 | [data-theme="cyberpunk-dark"] .widget-card h3, 136 | [data-theme="cyberpunk-dark"] .group h1, 137 | [data-theme="cyberpunk-dark"] .group h2, 138 | [data-theme="cyberpunk-dark"] .group h3, 139 | [data-theme="cyberpunk-dark"] .platform-tab h1, 140 | [data-theme="cyberpunk-dark"] .platform-tab h2, 141 | [data-theme="cyberpunk-dark"] .platform-tab h3, 142 | [data-theme="cyberpunk-dark"] .hot-news-item h1, 143 | [data-theme="cyberpunk-dark"] .hot-news-item h2, 144 | [data-theme="cyberpunk-dark"] .hot-news-item h3 { 145 | font-family: 'Orbitron', 'Rajdhani', 'Noto Sans SC', sans-serif; 146 | text-transform: uppercase; 147 | font-weight: 700; 148 | letter-spacing: 0.1em; 149 | } 150 | 151 | /* 通用组件样式 */ 152 | body { 153 | background-color: var(--background); 154 | color: var(--foreground); 155 | font-family: var(--font-sans); 156 | } 157 | 158 | .widget-card { 159 | background-color: var(--card); 160 | color: var(--card-foreground); 161 | border-radius: var(--radius-md); 162 | box-shadow: var(--shadow-sm); 163 | border: var(--border-width, 1px) var(--border-style, solid) var(--border, transparent); 164 | transition: var(--transition-all); 165 | } 166 | 167 | .widget-card:hover { 168 | box-shadow: var(--shadow-md); 169 | transform: translateY(-1px); 170 | } 171 | 172 | .platform-tab { 173 | background: transparent; 174 | color: var(--muted-foreground); 175 | border-radius: var(--radius-lg); 176 | transition: var(--transition-all); 177 | } 178 | 179 | .platform-tab:hover { 180 | background: var(--muted); 181 | color: var(--foreground); 182 | } 183 | 184 | .platform-tab-active { 185 | background: var(--primary); 186 | color: var(--primary-foreground); 187 | } 188 | 189 | .hot-news-item { 190 | border-left: 2px solid transparent; 191 | transition: var(--transition-all); 192 | } 193 | 194 | .hot-news-item:hover { 195 | border-left-color: var(--primary); 196 | background: var(--accent); 197 | } 198 | 199 | .hot-news-index { 200 | color: var(--muted-foreground); 201 | } 202 | 203 | .hot-news-title { 204 | color: var(--card-foreground); 205 | transition: var(--transition-all); 206 | } 207 | 208 | .hot-news-item:hover .hot-news-title { 209 | color: var(--primary); 210 | } 211 | 212 | .hot-news-views { 213 | color: var(--muted-foreground); 214 | } 215 | 216 | .hot-news-icon { 217 | color: var(--muted-foreground); 218 | } 219 | 220 | /* 赛博朋克特殊效果 */ 221 | [data-theme="cyberpunk-dark"] .widget-card { 222 | position: relative; 223 | border: none; 224 | border-radius: 0; 225 | background: linear-gradient(135deg, var(--muted) 0%, var(--background) 100%); 226 | box-shadow: 0 0 24px 2px hsla(286, 100%, 65%, 0.25), 0 0 8px 2px hsla(180, 100%, 55%, 0.15); 227 | color: #fff; 228 | font-family: 'Rajdhani', 'Noto Sans SC', sans-serif; 229 | overflow: hidden; 230 | } 231 | 232 | [data-theme="cyberpunk-dark"] .widget-card::before { 233 | content: ''; 234 | position: absolute; 235 | inset: 0; 236 | padding: 2px; 237 | border-radius: 0; 238 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 239 | z-index: 1; 240 | pointer-events: none; 241 | mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 242 | mask-composite: exclude; 243 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 244 | -webkit-mask-composite: xor; 245 | } 246 | 247 | [data-theme="cyberpunk-dark"] .widget-card > * { 248 | position: relative; 249 | z-index: 2; 250 | } 251 | 252 | [data-theme="cyberpunk-dark"] .platform-tab-active { 253 | background: linear-gradient(135deg, var(--primary), var(--secondary)); 254 | text-shadow: 0 0 10px currentColor; 255 | } 256 | 257 | [data-theme="cyberpunk-dark"] .group { 258 | position: relative; 259 | border: none; 260 | border-radius: 0; 261 | background: linear-gradient(135deg, var(--muted) 0%, var(--background) 100%); 262 | box-shadow: 0 0 24px 2px hsla(286, 100%, 65%, 0.18), 0 0 8px 2px hsla(180, 100%, 55%, 0.10); 263 | color: #fff; 264 | overflow: hidden; 265 | } 266 | 267 | [data-theme="cyberpunk-dark"] .group::before { 268 | content: ''; 269 | position: absolute; 270 | inset: 0; 271 | padding: 2px; 272 | border-radius: 0; 273 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 274 | z-index: 1; 275 | pointer-events: none; 276 | mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 277 | mask-composite: exclude; 278 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 279 | -webkit-mask-composite: xor; 280 | } 281 | 282 | [data-theme="cyberpunk-dark"] .group > * { 283 | position: relative; 284 | z-index: 2; 285 | } 286 | 287 | [data-theme="cyberpunk-dark"] .platform-tab { 288 | position: relative; 289 | border: none; 290 | border-radius: 0; 291 | background: linear-gradient(135deg, var(--muted) 0%, var(--background) 100%); 292 | color: #fff; 293 | overflow: hidden; 294 | } 295 | 296 | [data-theme="cyberpunk-dark"] .platform-tab::before { 297 | content: ''; 298 | position: absolute; 299 | inset: 0; 300 | padding: 2px; 301 | border-radius: 0; 302 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 303 | z-index: 1; 304 | pointer-events: none; 305 | mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 306 | mask-composite: exclude; 307 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 308 | -webkit-mask-composite: xor; 309 | } 310 | 311 | [data-theme="cyberpunk-dark"] .platform-tab > * { 312 | position: relative; 313 | z-index: 2; 314 | } 315 | 316 | [data-theme="cyberpunk-dark"] .hot-news-item { 317 | position: relative; 318 | border: none; 319 | border-radius: 0; 320 | background: linear-gradient(135deg, var(--muted) 0%, var(--background) 100%); 321 | color: #fff; 322 | overflow: hidden; 323 | } 324 | 325 | [data-theme="cyberpunk-dark"] .hot-news-item::before { 326 | content: ''; 327 | position: absolute; 328 | inset: 0; 329 | padding: 2px; 330 | border-radius: 0; 331 | background: linear-gradient(135deg, hsl(286, 100%, 65%), hsl(180, 100%, 55%)); 332 | z-index: 1; 333 | pointer-events: none; 334 | mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 335 | mask-composite: exclude; 336 | -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); 337 | -webkit-mask-composite: xor; 338 | } 339 | 340 | [data-theme="cyberpunk-dark"] .hot-news-item::after { 341 | content: ''; 342 | position: absolute; 343 | inset: 0; 344 | z-index: 0; 345 | pointer-events: none; 346 | background: radial-gradient(ellipse at 60% 40%, hsla(286,100%,65%,0.18) 0%, transparent 70%), 347 | radial-gradient(ellipse at 30% 80%, hsla(180,100%,55%,0.12) 0%, transparent 80%); 348 | filter: blur(12px); 349 | opacity: 0.7; 350 | } 351 | 352 | [data-theme="cyberpunk-dark"] .hot-news-item > * { 353 | position: relative; 354 | z-index: 2; 355 | } 356 | 357 | [data-theme="cyberpunk-dark"] .widget-card::after, 358 | [data-theme="cyberpunk-dark"] .group::after { 359 | content: ''; 360 | position: absolute; 361 | inset: 0; 362 | z-index: 0; 363 | pointer-events: none; 364 | background: radial-gradient(ellipse at 60% 40%, hsla(286,100%,65%,0.18) 0%, transparent 70%), 365 | radial-gradient(ellipse at 30% 80%, hsla(180,100%,55%,0.12) 0%, transparent 80%); 366 | filter: blur(12px); 367 | opacity: 0.7; 368 | } 369 | 370 | [data-theme="cyberpunk-dark"] .widget-card > *, 371 | [data-theme="cyberpunk-dark"] .group > * { 372 | position: relative; 373 | z-index: 2; 374 | } 375 | 376 | [data-theme="cyberpunk-dark"] .neon-title { 377 | color: hsl(286, 100%, 65%); 378 | text-shadow: 379 | 0 0 8px hsl(286, 100%, 65%), 380 | 0 0 16px hsl(286, 100%, 65%), 381 | 0 0 32px hsl(286, 100%, 65%), 382 | 0 0 48px hsl(286, 100%, 65%), 383 | 0 0 64px hsl(286, 100%, 65%), 384 | 0 0 80px hsl(180, 100%, 55%); 385 | animation: neon-flicker 2.5s infinite alternate; 386 | font-family: 'Orbitron', 'Rajdhani', 'Noto Sans SC', sans-serif; 387 | letter-spacing: 0.12em; 388 | font-weight: 700; 389 | text-transform: uppercase; 390 | position: relative; 391 | z-index: 2; 392 | } 393 | 394 | [data-theme="cyberpunk-dark"] .neon-title::after { 395 | content: ''; 396 | position: absolute; 397 | left: 50%; 398 | top: 50%; 399 | width: 180%; 400 | height: 180%; 401 | transform: translate(-50%, -50%); 402 | background: radial-gradient(circle, hsla(286, 100%, 65%, 0.25) 0%, transparent 70%); 403 | filter: blur(12px); 404 | z-index: -1; 405 | pointer-events: none; 406 | opacity: 0.8; 407 | } 408 | 409 | @keyframes neon-flicker { 410 | 0%, 100% { opacity: 1; filter: brightness(1.1); } 411 | 10% { opacity: 0.85; filter: brightness(1.3); } 412 | 20% { opacity: 0.95; filter: brightness(1.2); } 413 | 30% { opacity: 0.8; filter: brightness(1.4); } 414 | 40% { opacity: 1; filter: brightness(1.1); } 415 | 50% { opacity: 0.9; filter: brightness(1.3); } 416 | 60% { opacity: 1; filter: brightness(1.2); } 417 | 70% { opacity: 0.85; filter: brightness(1.4); } 418 | 80% { opacity: 1; filter: brightness(1.1); } 419 | 90% { opacity: 0.95; filter: brightness(1.3); } 420 | } -------------------------------------------------------------------------------- /src/types/lunar-javascript.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'lunar-javascript' { 2 | interface Solar { 3 | fromDate(date: Date): { getLunar(): Lunar }; 4 | } 5 | 6 | interface Lunar { 7 | getMonthInChinese(): string; 8 | getDayInChinese(): string; 9 | } 10 | 11 | const Solar: Solar; 12 | 13 | export { Solar }; 14 | } -------------------------------------------------------------------------------- /src/types/notion.ts: -------------------------------------------------------------------------------- 1 | // links page config 2 | export interface Link { 3 | id: string; 4 | name:string; 5 | created: string; 6 | tags: string[]; 7 | url: string; 8 | category1: string; 9 | category2: string; 10 | desc?: string; 11 | iconfile?: string; 12 | iconlink?: string; 13 | } 14 | 15 | // 网站配置类型 16 | export interface WebsiteConfig { 17 | [key: string]: string; 18 | } 19 | 20 | // 分类配置类型 21 | export interface Category { 22 | id: string; 23 | name: string; 24 | iconName: string; 25 | order: number; 26 | enabled: boolean; 27 | subCategories?: { 28 | id: string; 29 | name: string; 30 | }[]; 31 | } -------------------------------------------------------------------------------- /src/types/theme.ts: -------------------------------------------------------------------------------- 1 | // 主题配置类型定义 2 | 3 | // 主题样式对象类型 4 | export interface ThemeStyles { 5 | // 基础颜色 6 | primary: string; 7 | 'primary-foreground': string; 8 | background: string; 9 | 'background-foreground': string; 10 | muted: string; 11 | 'muted-foreground': string; 12 | accent: string; 13 | 'accent-foreground': string; 14 | card: string; 15 | 'card-foreground': string; 16 | border: string; 17 | popover: string; 18 | 'popover-foreground': string; 19 | 20 | // 字体相关 21 | 'font-family': string; 22 | 'font-size-base': string; 23 | 'font-size-sm': string; 24 | 'font-size-lg': string; 25 | 'font-weight-normal': string; 26 | 'font-weight-medium': string; 27 | 'font-weight-bold': string; 28 | 'line-height': string; 29 | 30 | // 卡片样式 31 | 'card-padding': string; 32 | 'card-radius': string; 33 | 'card-shadow': string; 34 | 'card-hover-shadow': string; 35 | 'card-border-width': string; 36 | 'card-border-style': string; 37 | 38 | // 边框和圆角 39 | 'border-radius-sm': string; 40 | 'border-radius-md': string; 41 | 'border-radius-lg': string; 42 | 'border-width': string; 43 | 'border-style': string; 44 | 45 | // 间距 46 | 'spacing-xs': string; 47 | 'spacing-sm': string; 48 | 'spacing-md': string; 49 | 'spacing-lg': string; 50 | 'spacing-xl': string; 51 | 52 | // 动画和过渡 53 | 'transition-duration': string; 54 | 'transition-timing': string; 55 | 56 | // 阴影 57 | 'shadow-sm': string; 58 | 'shadow-md': string; 59 | 'shadow-lg': string; 60 | 'shadow-hover': string; 61 | } 62 | 63 | // 主题配置类型 64 | export interface ThemeConfig { 65 | name: string; // 主题的唯一标识符 66 | displayName: string; // 显示在UI中的主题名称 67 | modes: string[]; // 支持的模式列表,如 ['light', 'dark'] 68 | styles: { // 主题样式配置 69 | [mode: string]: ThemeStyles; // 每个模式对应的样式配置 70 | }; 71 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | 4 | export default { 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | darkMode: "class", 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: "1rem", 15 | screens: { 16 | "2xl": "1400px", 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: "var(--border)", 22 | input: "var(--input)", 23 | ring: "var(--ring)", 24 | background: "var(--background)", 25 | foreground: "var(--foreground)", 26 | primary: { 27 | DEFAULT: "var(--primary)", 28 | foreground: "var(--primary-foreground)", 29 | }, 30 | secondary: { 31 | DEFAULT: "var(--secondary)", 32 | foreground: "var(--secondary-foreground)", 33 | }, 34 | destructive: { 35 | DEFAULT: "var(--destructive)", 36 | foreground: "var(--destructive-foreground)", 37 | }, 38 | muted: { 39 | DEFAULT: "var(--muted)", 40 | foreground: "var(--muted-foreground)", 41 | }, 42 | accent: { 43 | DEFAULT: "var(--accent)", 44 | foreground: "var(--accent-foreground)", 45 | }, 46 | popover: { 47 | DEFAULT: "var(--popover)", 48 | foreground: "var(--popover-foreground)", 49 | }, 50 | card: { 51 | DEFAULT: "var(--card)", 52 | foreground: "var(--card-foreground)", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: "var(--radius)", 57 | md: "calc(var(--radius) - 2px)", 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | fontFamily: { 61 | sans: ["var(--font-sans)", ...fontFamily.sans], 62 | mono: ["Consolas", "Monaco", "Liberation Mono", "Courier New", "monospace"], 63 | }, 64 | keyframes: { 65 | "accordion-down": { 66 | from: { height: "0" }, 67 | to: { height: "var(--radix-accordion-content-height)" }, 68 | }, 69 | "accordion-up": { 70 | from: { height: "var(--radix-accordion-content-height)" }, 71 | to: { height: "0" }, 72 | }, 73 | }, 74 | animation: { 75 | "accordion-down": "accordion-down 0.2s ease-out", 76 | "accordion-up": "accordion-up 0.2s ease-out", 77 | }, 78 | }, 79 | }, 80 | plugins: [ 81 | require('tailwind-scrollbar')({ nocompatible: true }), 82 | ], 83 | } satisfies Config; 84 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------