34 Commits

Author SHA1 Message Date
3e23f08eea chore: 不优化首页头像
Some checks failed
Deploy to K3s / deploy (push) Has been cancelled
2026-01-19 14:19:22 +08:00
88a017d6da feat: 优化控制台登陆深色模式表现
All checks were successful
Deploy to K3s / deploy (push) Successful in 1m27s
2026-01-03 15:10:10 +08:00
a718a5487a feat: 博客页支持深色模式 2026-01-03 15:06:17 +08:00
a04227016e chore: 修复首页图像警告 2026-01-03 14:54:45 +08:00
12724bea7f chore: 修复资源卡片图像的警告 2026-01-03 14:53:58 +08:00
720ca56eb3 feat: 添加资源页深色模式支持 2026-01-03 14:51:37 +08:00
8c01303c6c feat: 优化footer深色模式样式 2026-01-03 14:43:01 +08:00
5e2e18fce6 feat: 添加首页、header/footer组件深色模式支持 2026-01-03 14:41:38 +08:00
33053b4a92 secure: 更新nextjs...
All checks were successful
Deploy to K3s / deploy (push) Successful in 3m34s
2026-01-02 23:55:15 +08:00
1c518b44cc chore: 移除自动迁移...
All checks were successful
Deploy to K3s / deploy (push) Successful in 11s
2025-12-27 14:32:01 +08:00
cd80375cc5 chore: ..
Some checks failed
Deploy to K3s / deploy (push) Failing after 5m6s
2025-12-27 14:26:20 +08:00
c23e822cd6 chore: 尝试用job迁移数据库
Some checks failed
Deploy to K3s / deploy (push) Failing after 35s
2025-12-27 14:22:09 +08:00
375d12ab0f lint: 移除前端未使用的import
Some checks failed
Deploy to K3s / deploy (push) Failing after 2m4s
2025-12-27 14:15:05 +08:00
83bdc924b9 chore: 添加数据库迁移
Some checks failed
Deploy to K3s / deploy (push) Failing after 1m49s
2025-12-27 14:11:23 +08:00
c75a67c0d9 chore: 调整bloglist不返回deletedAt字段 2025-12-27 13:53:15 +08:00
0b9963bb29 feat: 调整sitemap支持slug 2025-12-27 13:50:44 +08:00
b48ed4d903 feat: 调整博客页以支持slug 2025-12-27 13:48:48 +08:00
b9d09a16ec feat: 优化博客表格 2025-12-27 13:25:11 +08:00
8c43f5fa73 feat: 编辑博客支持Slug字段,添加复制链接功能 2025-12-27 13:19:17 +08:00
3ea57ba023 chore: 调整复制分享链接命名 2025-12-27 13:14:46 +08:00
a932178509 refactor: 调整复制博客URL到通用函数 2025-12-27 13:13:16 +08:00
2c76d1380f feat: 创建博客时,不允许Slug为空 2025-12-27 13:07:37 +08:00
58b7f592fe feat: 创建博客支持slug字段了 2025-12-27 13:05:07 +08:00
a2e8ddebca feat: 博客添加slug字段 2025-12-27 12:26:58 +08:00
13ec36aa8f lint
All checks were successful
Deploy to K3s / deploy (push) Successful in 2m5s
2025-12-25 15:17:58 +08:00
db8d8c429d feat: 调整博客页获取数据部分为服务端渲染
Some checks failed
Deploy to K3s / deploy (push) Failing after 1m2s
2025-12-25 15:00:06 +08:00
8dc2473a1c feat: 优化handleAPIError函数,返回handler执行结果 2025-12-25 14:54:37 +08:00
616b1ad389 feat: 前端添加robots.ts和sitemap.ts
All checks were successful
Deploy to K3s / deploy (push) Successful in 4m17s
2025-12-24 14:03:34 +08:00
0ef987932f chore: 前端调整博客结构定义 2025-12-24 14:02:03 +08:00
004548c9df feat: 后端博客列表时,添加updatedAt字段 2025-12-24 13:59:30 +08:00
941633bdb4 chore: 移除next-sitemap... 2025-12-24 13:58:58 +08:00
abaa16a0f9 feat: 博客在站内打开 2025-12-24 13:42:56 +08:00
f64b9bb469 chore: 前端添加next-sitemap依赖 2025-12-24 13:42:29 +08:00
f2afe4f7ee fix: 修复首页下元数据错误的问题 2025-12-22 09:03:46 +08:00
33 changed files with 970 additions and 417 deletions

View File

@@ -5,6 +5,9 @@ export class CreateBlogDto {
@IsString() @IsString()
title: string; title: string;
@IsString()
slug: string;// 允许空串但如果为空则需要手动设置为null防止数据库唯一键冲突
@IsString() @IsString()
description: string; description: string;

View File

@@ -8,6 +8,9 @@ export class UpdateBlogDto {
@IsString() @IsString()
description: string; description: string;
@IsString()
slug: string;
@IsString() @IsString()
contentUrl: string; contentUrl: string;

View File

@@ -31,12 +31,16 @@ export class BlogController {
return this.blogService.list(); return this.blogService.list();
} }
@Get(':id') @Get(':id/slug')
async getBlog( async getBlogBySlug(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('id') slug: string,
@Query('p') password?: string, @Query('p') password?: string,
) { ) {
const blog = await this.blogService.findById(id); if (slug.trim().length === 0) {
throw new BadRequestException('文章不存在');
}
const blog = await this.blogService.findBySlug(slug);
if (!blog) throw new BadRequestException('文章不存在或无权限访问'); if (!blog) throw new BadRequestException('文章不存在或无权限访问');
if (!blog.permissions.includes(BlogPermission.Public)) { if (!blog.permissions.includes(BlogPermission.Public)) {
@@ -46,7 +50,7 @@ export class BlogController {
} else { } else {
// 判断密码是否正确 // 判断密码是否正确
if ( if (
!password || typeof password !== 'string' ||
this.blogService.hashPassword(password) !== blog.password_hash this.blogService.hashPassword(password) !== blog.password_hash
) { ) {
throw new BadRequestException('文章不存在或无权限访问'); throw new BadRequestException('文章不存在或无权限访问');
@@ -57,7 +61,7 @@ export class BlogController {
const blogDataRes = await fetch(`${blog.contentUrl}`); const blogDataRes = await fetch(`${blog.contentUrl}`);
const blogContent = await blogDataRes.text(); const blogContent = await blogDataRes.text();
await this.blogService.incrementViewCount(id); this.blogService.incrementViewCount(blog.id).catch(() => null);
return { return {
id: blog.id, id: blog.id,
title: blog.title, title: blog.title,

View File

@@ -35,12 +35,13 @@ export class BlogService {
return i; return i;
} }
const { createdAt, deletedAt, id, title, viewCount, description } = i; const { createdAt, updatedAt, id, title, viewCount, description, slug } = i;
return { return {
createdAt, createdAt,
deletedAt, updatedAt,
id, id,
title, title,
slug,
viewCount, viewCount,
description, description,
}; };
@@ -56,6 +57,9 @@ export class BlogService {
.digest('hex'); .digest('hex');
} }
} }
if (typeof blog.slug === 'string' && blog.slug.trim().length === 0) {
blog.slug = null;
}
const newBlog = this.blogRepository.create(blog); const newBlog = this.blogRepository.create(blog);
return this.blogRepository.save(newBlog); return this.blogRepository.save(newBlog);
@@ -92,6 +96,12 @@ export class BlogService {
return await this.blogRepository.findOneBy({ id }); return await this.blogRepository.findOneBy({ id });
} }
async findBySlug(slug: string) {
return this.blogRepository.findOne({
where: { slug }
})
}
async incrementViewCount(id: string) { async incrementViewCount(id: string) {
await this.blogRepository.increment({ id }, 'viewCount', 1); await this.blogRepository.increment({ id }, 'viewCount', 1);
} }

View File

@@ -16,6 +16,9 @@ export class Blog {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ unique: true, nullable: true })
slug: string;
@Column() @Column()
title: string; title: string;

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSlugToBlog1766809565876 implements MigrationInterface {
name = 'AddSlugToBlog1766809565876'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blog" ADD "slug" character varying`);
await queryRunner.query(`ALTER TABLE "blog" ADD CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599" UNIQUE ("slug")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blog" DROP CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599"`);
await queryRunner.query(`ALTER TABLE "blog" DROP COLUMN "slug"`);
}
}

View File

@@ -0,0 +1,41 @@
'use client';
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import { PhotoProvider, PhotoView } from 'react-photo-view';
import 'react-photo-view/dist/react-photo-view.css';
import rehypeRaw from 'rehype-raw'
import Image from "next/image";
export function BlogContent({ content }: { content?: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
components={{
h1: ({ ...props }) => <h2 className="text-3xl font-bold py-2" {...props} />,
h2: ({ ...props }) => <h3 className="text-2xl font-bold py-1" {...props} />,
h3: ({ ...props }) => <h4 className="text-xl font-bold py-0.5" {...props} />,
h4: ({ ...props }) => <h5 className="text-lg font-bold" {...props} />,
h5: ({ ...props }) => <h6 className="text-md font-bold" {...props} />,
p: ({ ...props }) => <p className="py-1 text-zinc-700 dark:text-zinc-300" {...props} />,
img: ({ src }) => (
<PhotoProvider className="w-full">
<PhotoView src={src as string}>
<div style={{ width: '100%' }}>
<Image src={src as string} width={0} height={0} style={{ width: '100%', height: 'auto' }} unoptimized alt="加载失败" />
</div>
</PhotoView>
</PhotoProvider>
),
th: ({ ...props }) => <th className="text-ellipsis text-nowrap border border-zinc-300 dark:border-zinc-500 p-2" {...props} />,
td: ({ ...props }) => <td className="border border-zinc-300 dark:border-zinc-500 p-1" {...props} />,
table: ({ ...props }) => <div className="overflow-x-auto"><table {...props} /></div>,
pre: ({ ...props }) => <pre className="rounded-sm overflow-hidden shadow" {...props} />,
blockquote: ({ ...props }) => <blockquote className="pl-3 border-l-5" {...props} />,
a: ({ ...props }) => <a className="hover:underline" {...props} />,
}}
>{content}</ReactMarkdown>
)
}

View File

@@ -37,7 +37,7 @@ export function BlogComments({ blogId }: { blogId: string }) {
handleClearReplayTarget={() => setReplayTarget(null)} handleClearReplayTarget={() => setReplayTarget(null)}
/> />
<div className="text-sm text-zinc-600"> <div className="text-sm text-zinc-600 dark:text-zinc-400">
{ {
user ? (<span>{user.nickname}</span>) : (<span></span>) user ? (<span>{user.nickname}</span>) : (<span></span>)
} }
@@ -47,21 +47,21 @@ export function BlogComments({ blogId }: { blogId: string }) {
{ {
data.filter(d => !d.parentId) data.filter(d => !d.parentId)
.map((d) => ( .map((d) => (
<div key={d.id} className="border-b border-zinc-300 py-2 last:border-none"> <div key={d.id} className="border-b border-zinc-300 dark:border-zinc-500 py-2 last:border-none">
<h1 className="text-zinc-500">{d.user ? d.user.nickname : '匿名'}</h1> <h1 className="text-zinc-500 dark:text-zinc-200">{d.user ? d.user.nickname : '匿名'}</h1>
<div className="whitespace-pre-wrap break-all">{d.content}</div> <div className="whitespace-pre-wrap break-all">{d.content}</div>
<div className="text-xs text-zinc-500 flex gap-2"> <div className="text-xs text-zinc-500 flex gap-2">
<p>{new Date(d.createdAt).toLocaleString()}</p> <p>{new Date(d.createdAt).toLocaleString()}</p>
<p>{d.address}</p> <p>{d.address}</p>
<p className="text-zinc-900 cursor-pointer" onClick={() => setReplayTarget(d)}></p> <p className="text-zinc-900 dark:text-zinc-200 cursor-pointer" onClick={() => setReplayTarget(d)}></p>
</div> </div>
{ {
data.filter(c => c.parentId === d.id).length > 0 && ( data.filter(c => c.parentId === d.id).length > 0 && (
<div className="flex flex-col ml-5 my-1"> <div className="flex flex-col ml-5 my-1">
{ {
data.filter(c => c.parentId === d.id).map(c => ( data.filter(c => c.parentId === d.id).map(c => (
<div key={c.id} className="border-b border-zinc-300 py-1 last:border-none"> <div key={c.id} className="border-b border-zinc-300 dark:border-zinc-500 py-1 last:border-none">
<h1 className="text-zinc-500">{c.user ? c.user.nickname : '匿名'}</h1> <h1 className="text-zinc-500 dark:text-zinc-200">{c.user ? c.user.nickname : '匿名'}</h1>
<div className="whitespace-pre-wrap break-all">{c.content}</div> <div className="whitespace-pre-wrap break-all">{c.content}</div>
<div className="text-xs text-zinc-500 flex gap-2"> <div className="text-xs text-zinc-500 flex gap-2">
<p>{new Date().toLocaleString()}</p> <p>{new Date().toLocaleString()}</p>

View File

@@ -1,96 +1,91 @@
'use client'; import { BlogContent } from "./BlogContent";
import { BlogAPI } from "@/lib/api/server";
import { base62 } from "@/lib/utils"; import { handleAPIError } from "@/lib/api/common";
import { useParams, useSearchParams } from "next/navigation";
import useSWR from "swr";
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import { PhotoProvider, PhotoView } from 'react-photo-view';
import 'react-photo-view/dist/react-photo-view.css';
import rehypeRaw from 'rehype-raw'
import { Skeleton } from "@/components/ui/skeleton";
import { BlogComments } from "./components/BlogComments"; import { BlogComments } from "./components/BlogComments";
import Image from "next/image";
import { BlogAPI } from "@/lib/api/client";
import { useEffect } from "react";
export default function Blog() { interface PageRouteProps {
const params = useParams(); params: Promise<{ id: string }>
const searchParams = useSearchParams(); searchParams: Promise<{
[key: string]: string | string[] | undefined;
} | undefined>
}
const hex = Array.from(base62.decode(params.id as string)).map(b => b.toString(16).padStart(2, '0')).join(''); async function parseBlogParams({ params: paramsPromise, searchParams: searchParamsPromise }: PageRouteProps) {
const id = [ const params = await paramsPromise ?? {};
hex.slice(0, 8), const searchParams = await searchParamsPromise ?? {};
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32)
].join('-');
const password = searchParams.get('p'); if (Array.isArray(searchParams.p)) {
const { data, error, isLoading } = useSWR( return {
`/api/blog/${id}`, errorMsg: '密码错误或文章不存在'
() => BlogAPI.getBlog(id, password || undefined), }
) }
useEffect(() => { if (typeof params.id !== 'string' || params.id.trim() === '') {
return {
errorMsg: '文章不存在或无权限访问'
}
}
return {
id: params.id,
p: searchParams.p,
}
}
async function getBlog(paramsResult: ReturnType<typeof parseBlogParams>) {
const { errorMsg, id, p } = await paramsResult;
if (errorMsg) {
return {
errorMsg,
}
} else {
try {
const data = await BlogAPI.getBlogBySlug(`${id}`, p);
return {
data,
}
} catch (error) {
return {
errorMsg: handleAPIError(error, ({ message }) => message)
}
}
}
}
export async function generateMetadata({ params, searchParams }: PageRouteProps) {
const { errorMsg, data } = await getBlog(parseBlogParams({ params, searchParams }));
if (data) { if (data) {
document.title = `${data.title} - 特恩的日志`; return {
const metaDescription = document.querySelector('meta[name="description"]'); title: `${data.title} - 特恩的日志`,
if (metaDescription) { description: `${data.description}`
metaDescription.setAttribute("content", data.description); }
} else {
return {
title: `${errorMsg || '错误'} - 特恩的日志`,
description: `出错啦`
} }
} }
}, [data]); }
export default async function Page({ params, searchParams }: PageRouteProps) {
const res = await parseBlogParams({ params, searchParams });
const { id, p } = res;
let { errorMsg } = res;
const data = errorMsg ? null
: await BlogAPI.getBlogBySlug(`${id}`, p).catch(e => handleAPIError(e, ({ message }) => { errorMsg = message; return null }));
return ( return (
<div className="w-full overflow-x-hidden"> <div className="w-full overflow-x-hidden">
<div className="max-w-200 mx-auto px-5 overflow-x-hidden mb-10"> <div className="max-w-200 mx-auto px-5 overflow-x-hidden mb-10">
{error && <div className="my-20 text-center text-zinc-600">{error.message}</div>} {errorMsg && <div className="my-20 text-center text-zinc-600 dark:text-zinc-400">{errorMsg}</div>}
{isLoading && (
<div className="flex flex-col gap-2 mt-10">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-20" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-20" />
<Skeleton className="w-full h-30" />
</div>
)}
{data && ( {data && (
<article className="w-full"> <article className="w-full">
<header className="flex flex-col items-center"> <header className="flex flex-col items-center">
<h1 className="text-center text-2xl sm:text-3xl font-bold mt-10 transition-all duration-500">{data.title}</h1> <h1 className="text-center text-2xl sm:text-3xl font-bold mt-10 transition-all duration-500">{data.title}</h1>
<time className="text-sm text-zinc-500 text-center my-2 sm:my-5 mb-5 transition-all duration-500">{new Date(data.createdAt).toLocaleString()}</time> <time className="text-sm text-zinc-500 dark:text-zinc-300 text-center my-2 sm:my-5 mb-5 transition-all duration-500">{new Date(data.createdAt).toLocaleString()}</time>
</header> </header>
<ReactMarkdown <BlogContent content={data.content} />
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
components={{
h1: ({ ...props }) => <h2 className="text-3xl font-bold py-2" {...props} />,
h2: ({ ...props }) => <h3 className="text-2xl font-bold py-1" {...props} />,
h3: ({ ...props }) => <h4 className="text-xl font-bold py-0.5" {...props} />,
h4: ({ ...props }) => <h5 className="text-lg font-bold" {...props} />,
h5: ({ ...props }) => <h6 className="text-md font-bold" {...props} />,
p: ({ ...props }) => <p className="py-1 text-zinc-700" {...props} />,
img: ({ src }) => (
<PhotoProvider className="w-full">
<PhotoView src={src as string}>
<div style={{ width: '100%' }}>
<Image src={src as string} width={0} height={0} style={{ width: '100%', height: 'auto' }} unoptimized alt="加载失败" />
</div>
</PhotoView>
</PhotoProvider>
),
th: ({ ...props }) => <th className="text-ellipsis text-nowrap border border-zinc-300 p-2" {...props} />,
td: ({ ...props }) => <td className="border border-zinc-300 p-1" {...props} />,
table: ({ ...props }) => <div className="overflow-x-auto"><table {...props} /></div>,
pre: ({ ...props }) => <pre className="rounded-sm overflow-hidden shadow" {...props} />,
blockquote: ({ ...props }) => <blockquote className="pl-3 border-l-5" {...props} />,
a: ({ ...props }) => <a className="hover:underline" {...props} />,
}}
>{data.content}</ReactMarkdown>
</article> </article>
)} )}

View File

@@ -4,7 +4,6 @@ import {
AlertTitle, AlertTitle,
} from "@/components/ui/alert"; } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { base62 } from "@/lib/utils";
import { BlogAPI } from "@/lib/api/server"; import { BlogAPI } from "@/lib/api/server";
import { handleAPIError } from "@/lib/api/common"; import { handleAPIError } from "@/lib/api/common";
@@ -18,13 +17,10 @@ const formatNumber = (num: number): string => {
return num.toString(); return num.toString();
}; };
const getBlogDetailUrl = (id: string): string => { const getBlogDetailUrl = (slug: string): string => {
const cleanId = id.replace(/-/g, ''); return `/blog/${slug}`;
const encoded = base62.encode(Buffer.from(cleanId, 'hex'));
return `/blog/${encoded}`;
}; };
export const metadata = { export const metadata = {
title: '日志 - 特恩的日志', title: '日志 - 特恩的日志',
description: '我随便发点,你也随便看看~', description: '我随便发点,你也随便看看~',
@@ -56,14 +52,13 @@ export default async function Blog() {
<h2 className="text-2xl font-medium"> <h2 className="text-2xl font-medium">
<a <a
className="hover:underline focus:outline-none focus:ring-2 focus:ring-zinc-400 rounded" className="hover:underline focus:outline-none focus:ring-2 focus:ring-zinc-400 rounded"
href={getBlogDetailUrl(blog.id)} href={getBlogDetailUrl(blog.slug)}
target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{blog.title} {blog.title}
</a> </a>
</h2> </h2>
<p className="text-sm font-medium text-zinc-600">{blog.description}</p> <p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">{blog.description}</p>
<footer className="mt-3 text-sm text-zinc-500 flex items-center gap-2"> <footer className="mt-3 text-sm text-zinc-500 flex items-center gap-2">
<time dateTime={blog.createdAt}> <time dateTime={blog.createdAt}>
{new Date(blog.createdAt).toLocaleString('zh-CN')} {new Date(blog.createdAt).toLocaleString('zh-CN')}

View File

@@ -9,7 +9,7 @@ export default function LayoutWithHeaderFooter({
return ( return (
<> <>
<Header /> <Header />
<main className="flex-1 flex flex-col bg-zinc-50"> <main className="flex-1 flex flex-col bg-zinc-50 dark:bg-zinc-950">
{children} {children}
</main> </main>
<Footer /> <Footer />

View File

@@ -10,12 +10,12 @@ export default function Home() {
width={180} width={180}
height={180} height={180}
className="rounded-full duration-400 size-35 md:size-45 select-none" className="rounded-full duration-400 size-35 md:size-45 select-none"
unoptimized
priority priority
quality={100}
/> />
</figure> </figure>
<h1 className='text-4xl md:text-5xl font-bold mt-5 md:mt-8 gradient-title duration-400 select-none'>(TONE)</h1> <h1 className='text-4xl md:text-5xl font-bold mt-5 md:mt-8 gradient-title duration-400 select-none'>(TONE)</h1>
<p className='text-lg sm:text-xl md:text-2xl mt-3 font-medium text-zinc-400 duration-400 select-none'></p> <p className='text-lg sm:text-xl md:text-2xl mt-3 font-medium text-zinc-400 dark:text-zinc-200 duration-400 select-none'></p>
<nav className='flex sm:flex-row flex-col gap-2 sm:gap-10 mt-5 md:mt-8 duration-400' aria-label="社交媒体链接"> <nav className='flex sm:flex-row flex-col gap-2 sm:gap-10 mt-5 md:mt-8 duration-400' aria-label="社交媒体链接">
<a href='https://space.bilibili.com/474156211' <a href='https://space.bilibili.com/474156211'
target='_black' target='_black'

View File

@@ -18,7 +18,7 @@ export function ResourceCard({ r, ...props }: ResourceCardProps) {
</div> </div>
<div className="flex-1 overflow-x-hidden"> <div className="flex-1 overflow-x-hidden">
<div className="font-bold text-2xl">{r.title}</div> <div className="font-bold text-2xl">{r.title}</div>
<div className="font-medium text-sm text-zinc-400 mt-1">{r.description}</div> <div className="font-medium text-sm text-zinc-400 dark:text-zinc-300 mt-1">{r.description}</div>
<div className="flex gap-2 flex-wrap mt-4"> <div className="flex gap-2 flex-wrap mt-4">
{ {
r.tags.map((tag) => ( r.tags.map((tag) => (

View File

@@ -16,9 +16,9 @@ export default function ResourceCardImage({ imageUrl }: ResourceCardImage) {
alt="资源图片" alt="资源图片"
width={90} width={90}
height={90} height={90}
className="rounded-md shadow" className="rounded-md shadow w-22.5 h-22.5"
priority priority
quality={80} quality={75}
onError={() => setImageError(true)} onError={() => setImageError(true)}
/>} />}
</> </>

View File

@@ -18,9 +18,9 @@ export default async function Resources() {
return ( return (
<div className="flex-1 flex flex-col items-center"> <div className="flex-1 flex flex-col items-center">
<h1 className="mt-6 md:mt-20 text-2xl md:text-5xl font-medium text-zinc-800 text-center duration-300"></h1> <h1 className="mt-6 md:mt-20 text-2xl md:text-5xl font-medium text-zinc-800 dark:text-zinc-200 text-center duration-300"></h1>
<p className="mt-4 md:mt-8 mx-3 text-zinc-400 text-sm text-center duration-300"> <p className="mt-4 md:mt-8 mx-3 text-zinc-400 dark:text-zinc-300 text-sm text-center duration-300">
<a className="text-zinc-600">使</a> <a className="text-zinc-600 dark:text-zinc-400">使</a>
使</p> 使</p>
{ {
errorMsg && ( errorMsg && (

View File

@@ -17,6 +17,7 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs"; import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs";
import { AdminAPI } from "@/lib/api/client"; import { AdminAPI } from "@/lib/api/client";
import { copyShareURL } from "./utils";
interface AddBlogProps { interface AddBlogProps {
children: React.ReactNode; children: React.ReactNode;
@@ -27,6 +28,7 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [blog, setBlog] = useState({ const [blog, setBlog] = useState({
title: "", title: "",
slug: "",
description: "", description: "",
contentUrl: "", contentUrl: "",
permissions: [] as BlogPermission[], permissions: [] as BlogPermission[],
@@ -44,6 +46,7 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
toast.success("添加成功"); toast.success("添加成功");
setBlog({ setBlog({
title: '', title: '',
slug: '',
description: '', description: '',
contentUrl: '', contentUrl: '',
permissions: [], permissions: [],
@@ -92,6 +95,17 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
onChange={(e) => setBlog({ ...blog, description: e.target.value })} onChange={(e) => setBlog({ ...blog, description: e.target.value })}
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="slug" className="text-right">
Slug
</Label>
<Input
id="slug"
className="col-span-3"
value={blog.slug}
onChange={(e) => setBlog({ ...blog, slug: e.target.value })}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="contentUrl" className="text-right"> <Label htmlFor="contentUrl" className="text-right">
URL URL
@@ -136,9 +150,18 @@ export default function AddBlog({ children, onRefresh }: AddBlogProps) {
</div> </div>
} }
</div> </div>
<DialogFooter> <DialogFooter >
<div className="flex justify-between w-full">
<Button type="button" variant='outline' onClick={() => copyShareURL({
slug: blog.slug,
password: blog.password,
permissions: blog.permissions,
})}></Button>
<div>
<Button type="button" variant='secondary' onClick={() => setOpen(false)}></Button> <Button type="button" variant='secondary' onClick={() => setOpen(false)}></Button>
<Button type="button" onClick={handleSubmit}></Button> <Button type="button" onClick={handleSubmit}></Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -20,6 +20,7 @@ import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs"
import { BlogPermission } from "@/lib/types/Blog.Permission.enum" import { BlogPermission } from "@/lib/types/Blog.Permission.enum"
import { SetPasswordDialog } from "./SetPasswordDialog" import { SetPasswordDialog } from "./SetPasswordDialog"
import { AdminAPI } from "@/lib/api/client" import { AdminAPI } from "@/lib/api/client"
import { copyShareURL } from "./utils"
interface BlogEditProps { interface BlogEditProps {
id: string; id: string;
@@ -46,6 +47,7 @@ export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
await AdminAPI.updateBlog(id, { await AdminAPI.updateBlog(id, {
title: blog.title, title: blog.title,
description: blog.description, description: blog.description,
slug: blog.slug,
contentUrl: blog.contentUrl, contentUrl: blog.contentUrl,
permissions: blog.permissions, permissions: blog.permissions,
}); });
@@ -106,6 +108,17 @@ export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
onChange={(e) => mutate({ ...blog, description: e.target.value }, false)} onChange={(e) => mutate({ ...blog, description: e.target.value }, false)}
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="slug" className="text-right">
Slug
</Label>
<Input
id="slug"
className="col-span-3"
value={blog.slug}
onChange={(e) => mutate({ ...blog, slug: e.target.value }, false)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="contentUrl" className="text-right"> <Label htmlFor="contentUrl" className="text-right">
URL URL
@@ -151,6 +164,11 @@ export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<div> <div>
<Button variant='destructive' onClick={handleDelete}></Button> <Button variant='destructive' onClick={handleDelete}></Button>
<Button variant='outline' className="ml-2" onClick={() => copyShareURL({
slug: blog.slug,
permissions: blog.permissions,
password: ''
})}></Button>
</div> </div>
<div> <div>
<Button type="button" variant='secondary' onClick={() => setOpen(false)}></Button> <Button type="button" variant='secondary' onClick={() => setOpen(false)}></Button>

View File

@@ -28,10 +28,11 @@ export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
} }
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-25">Id</TableHead> <TableHead className="w-15">Id</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead>URL</TableHead> <TableHead>Slug</TableHead>
<TableHead className="w-25">URL</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -42,7 +43,7 @@ export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="max-w-[100px] overflow-hidden text-ellipsis">{blog.id}</div> <div className="max-w-15 overflow-hidden text-ellipsis">{blog.id}</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{blog.id}</p> <p>{blog.id}</p>
@@ -52,7 +53,19 @@ export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
</TableCell> </TableCell>
<TableCell className="whitespace-normal break-all">{blog.title}</TableCell> <TableCell className="whitespace-normal break-all">{blog.title}</TableCell>
<TableCell className="whitespace-normal break-all">{blog.description}</TableCell> <TableCell className="whitespace-normal break-all">{blog.description}</TableCell>
<TableCell className="whitespace-normal break-all">{blog.contentUrl}</TableCell> <TableCell className="whitespace-normal break-all">{blog.slug}</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-20 overflow-hidden text-ellipsis">{blog.contentUrl}</div>
</TooltipTrigger>
<TooltipContent>
<p>{blog.contentUrl}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<BlogEdit id={blog.id} onRefresh={() => onRefresh()}> <BlogEdit id={blog.id} onRefresh={() => onRefresh()}>
<Button variant={'outline'} size={'sm'}></Button> <Button variant={'outline'} size={'sm'}></Button>

View File

@@ -0,0 +1,32 @@
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
import { toast } from "sonner";
export function copyShareURL(data: {
slug: string;
password: string;
permissions: BlogPermission[];
}) {
const slug = data.slug.trim();
const password = data.password.trim();
const permissions = data.permissions;
if (slug.length === 0) {
return toast.warning('请先填写Slug')
}
let url = `${window.location.origin}/blog/${slug}`;
if (permissions.includes(BlogPermission.ByPassword)) {
if (password.length === 0) {
return toast.warning('开启了密码保护但无法获取有效的密码无法生成有效URL')
} else {
url += `?p=${password}`;
}
}
navigator.clipboard.writeText(url).then(() => {
toast.success('复制成功');
}, () => {
toast.error('复制失败,请手动复制');
});
};

View File

@@ -97,7 +97,7 @@ export default function Login() {
alt="Image" alt="Image"
width={500} width={500}
height={500} height={500}
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale" className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.6] dark:grayscale"
priority priority
quality={100} quality={100}
/> />

View File

@@ -17,8 +17,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "控制台 - 特恩的日志", title: "特恩的日志",
description: "登录或注册以解锁更多妙妙小工具", description: "一名在各个领域反复横跳的程序员",
}; };
export default async function RootLayout({ export default async function RootLayout({

View File

@@ -0,0 +1,12 @@
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/console',
},
sitemap: 'https://www.tonesc.cn/sitemap.xml',
}
}

View File

@@ -0,0 +1,42 @@
import { BlogAPI } from '@/lib/api/server'
import { MetadataRoute } from 'next'
export const revalidate = 3600;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// 获取所有博客
const blogs = await BlogAPI.list().catch(() => [])
const blogUrls = blogs.map(blog => {
return {
url: `https://www.tonesc.cn/blog/${blog.slug}`,
lastModified: new Date(blog.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
}
})
// 静态页面
const staticUrls = [
{
url: 'https://www.tonesc.cn/',
lastModified: new Date(),
changeFrequency: 'yearly' as const,
priority: 1,
},
{
url: 'https://www.tonesc.cn/blog',
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
{
url: 'https://www.tonesc.cn/resource',
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
]
return [...staticUrls, ...blogUrls]
}

View File

@@ -10,19 +10,19 @@ const EMAIL = "tonesc.cn@gmail.com";
export default function Footer() { export default function Footer() {
return ( return (
<footer className="border-t border-zinc-300"> <footer className="border-t border-zinc-300 dark:border-zinc-500">
<div className="bg-zinc-50 px-4 py-3 md:py-5 sm:px-10 md:px-20 flex flex-col sm:flex-row justify-between items-center gap-4 transition-all"> <div className="bg-zinc-50 dark:bg-zinc-950 px-4 py-3 md:py-5 sm:px-10 md:px-20 flex flex-col sm:flex-row justify-between items-center gap-4 transition-all">
{/* 版权与备案信息 */} {/* 版权与备案信息 */}
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<a <a
href="https://beian.miit.gov.cn/" href="https://beian.miit.gov.cn/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block text-sm text-zinc-500 hover:text-zinc-700 hover:underline focus:outline-none focus:underline" className="block text-sm text-zinc-500 dark:text-zinc-300 hover:text-zinc-700 dark:hover:text-zinc-100 hover:underline focus:outline-none focus:underline"
> >
ICP备2023009516号-1 ICP备2023009516号-1
</a> </a>
<p className="mt-1 text-sm text-zinc-500"> <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-300">
© {new Date().getFullYear()} TONE Page. All rights reserved. © {new Date().getFullYear()} TONE Page. All rights reserved.
</p> </p>
</div> </div>
@@ -32,13 +32,13 @@ export default function Footer() {
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant='outline' size='sm' > <Button variant='outline' size='sm' >
<Mail className="text-zinc-600" /> <Mail className="text-zinc-600 dark:text-zinc-300" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-fit"> <PopoverContent className="w-fit">
<a <a
href={`mailto:${EMAIL}`} href={`mailto:${EMAIL}`}
className="text-sm text-zinc-800 hover:underline focus:outline-none focus:underline" className="text-sm text-zinc-800 dark:text-zinc-200 hover:underline focus:outline-none focus:underline"
> >
{EMAIL} {EMAIL}
</a> </a>

View File

@@ -48,13 +48,13 @@ export default function Header() {
return ( return (
<> <>
<header className="sticky top-0 z-50 backdrop-blur-sm bg-white/40 shadow" role="banner" aria-label="网站顶部导航栏"> <header className="sticky top-0 z-50 backdrop-blur-sm bg-white/40 dark:bg-black/40 shadow dark:shadow-zinc-500" role="banner" aria-label="网站顶部导航栏">
<div className="flex items-center justify-between px-10 md:h-18 md:px-20 h-14 duration-300" aria-label="主菜单"> <div className="flex items-center justify-between px-10 md:h-18 md:px-20 h-14 duration-300" aria-label="主菜单">
<Link <Link
href="/" href="/"
className={cn( className={cn(
"cursor-pointer font-medium text-zinc-500 hover:text-zinc-800 border-b-4 border-transparent duration-200", "cursor-pointer font-medium text-zinc-500 dark:text-zinc-300 hover:text-zinc-800 dark:hover:text-zinc-100 border-b-4 border-transparent duration-200",
pathname === "/" && "text-zinc-800" pathname === "/" && "text-zinc-800 dark:text-zinc-100"
)} )}
aria-current={pathname === "/" ? "page" : undefined} aria-current={pathname === "/" ? "page" : undefined}
> >
@@ -72,8 +72,8 @@ export default function Header() {
key={item.name} key={item.name}
href={item.path} href={item.path}
className={cn( className={cn(
"cursor-pointer md:text-lg font-medium text-zinc-500 hover:text-zinc-800 border-b-4 border-transparent duration-200", "cursor-pointer md:text-lg font-medium text-zinc-500 dark:text-zinc-300 hover:text-zinc-800 dark:hover:text-zinc-100 border-b-4 border-transparent duration-200",
pathname.startsWith(item.path) && "text-zinc-800 border-b-pink-500" pathname.startsWith(item.path) && "text-zinc-800 dark:text-zinc-100 border-b-pink-500"
)} )}
onClick={e => handleClick(e, item.path)} onClick={e => handleClick(e, item.path)}
aria-current={pathname === item.path ? "page" : undefined} aria-current={pathname === item.path ? "page" : undefined}

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { X } from "lucide-react"; import { X } from "lucide-react";
interface ResourceBadgeProps extends React.HTMLProps<HTMLDivElement> { interface ResourceBadgeProps extends React.HTMLProps<HTMLDivElement> {
@@ -10,23 +11,17 @@ export function ResourceBadge({ tag, editMode, onClose, ...props }: ResourceBadg
return ( return (
<div <div
id={tag.name} id={tag.name}
className="text-[10px] text-zinc-500 font-medium py-[1px] px-1.5 rounded-full flex items-center gap-1" className={cn(
style={{ "text-[10px] text-zinc-500 dark:text-zinc-300 dark:border font-medium py-px px-1.5 rounded-full flex items-center gap-1",
backgroundColor: (() => { 'bg-[#e4e4e7] dark:bg-[#2d2d30]',
switch (tag.type) { tag.type === 'os' || 'bg-[#dbedfd] dark:bg-[#1e3a5f]',
case 'os': )}
return '#dbedfd';
default:
return '#e4e4e7';
}
})()
}}
{...props} {...props}
> >
<span className="text-nowrap">{tag.name}</span> <span className="text-nowrap">{tag.name}</span>
{ {
editMode && ( editMode && (
<span onClick={() => onClose?.(tag.name)}><X className="w-3 h-3 text-zinc-800 cursor-pointer" /></span> <span onClick={() => onClose?.(tag.name)}><X className="w-3 h-3 text-zinc-800 dark:text-zinc-200 cursor-pointer" /></span>
) )
} }
</div> </div>

View File

@@ -52,7 +52,7 @@ export function normalizeAPIError(error: unknown): never {
throw new APIError((error instanceof Error ? `${error.message}` : '') || '未知错误', 400); throw new APIError((error instanceof Error ? `${error.message}` : '') || '未知错误', 400);
} }
export function handleAPIError(error: unknown, handler: (e: APIError) => void): void { export function handleAPIError<T>(error: unknown, handler: (e: APIError) => T): T {
if (error instanceof APIError) { if (error instanceof APIError) {
return handler(error); return handler(error);
} }

View File

@@ -3,6 +3,7 @@ import { clientFetch } from "../client";
import { Blog } from "@/lib/types/blog"; import { Blog } from "@/lib/types/blog";
import { BlogPermission } from "@/lib/types/Blog.Permission.enum"; import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
import { Role } from "@/lib/types/role"; import { Role } from "@/lib/types/role";
import { APIError } from "../common";
export interface UserEntity { export interface UserEntity {
userId: string; userId: string;
@@ -87,9 +88,31 @@ export async function updateResource(id: string, data: UpdateResourceParams) {
interface CreateBlogParams { interface CreateBlogParams {
title: string; title: string;
description: string; description: string;
slug: string;
contentUrl: string; contentUrl: string;
permissions: BlogPermission[];
password: string;
} }
export async function createBlog(data: CreateBlogParams) { export async function createBlog(data: CreateBlogParams) {
data.title = data.title.trim()
data.description = data.description.trim()
data.slug = data.slug.trim()
data.contentUrl = data.contentUrl.trim()
data.password = data.password.trim()
if (data.title.length === 0) {
throw new APIError('标题不得为空')
}
if (data.description.length === 0) {
throw new APIError('描述不得为空')
}
if (data.slug.length === 0) {
throw new APIError('Slug不得为空')
}
if (data.contentUrl.length === 0) {
throw new APIError('文章URL不得为空')
}
return clientFetch<Blog>('/api/admin/web/blog', { return clientFetch<Blog>('/api/admin/web/blog', {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -115,14 +138,29 @@ export async function removeBlog(id: string) {
interface UpdateBlogParams { interface UpdateBlogParams {
title: string; title: string;
description: string; description: string;
slug: string;
contentUrl: string; contentUrl: string;
permissions: BlogPermission[], permissions: BlogPermission[],
} }
export async function updateBlog(id: string, data: UpdateBlogParams) { export async function updateBlog(id: string, data: UpdateBlogParams) {
data.title = data.title.trim(); data.title = data.title.trim();
data.description = data.description.trim(); data.description = data.description.trim();
data.slug = data.slug.trim();
data.contentUrl = data.contentUrl.trim(); data.contentUrl = data.contentUrl.trim();
if (data.title.length === 0) {
throw new APIError('标题不得为空')
}
if (data.description.length === 0) {
throw new APIError('描述不得为空')
}
if (data.slug.length === 0) {
throw new APIError('Slug不得为空')
}
if (data.contentUrl.length === 0) {
throw new APIError('文章URL不得为空')
}
return clientFetch<Blog>(`/api/admin/web/blog/${id}`, { return clientFetch<Blog>(`/api/admin/web/blog/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) body: JSON.stringify(data)

View File

@@ -2,5 +2,18 @@ import { Blog } from "@/lib/types/blog";
import { serverFetch } from "../server"; import { serverFetch } from "../server";
export async function list() { export async function list() {
return serverFetch<Blog[]>('/api/blog') return serverFetch<Pick<Blog,
'id' | 'title' | 'slug' | 'description' | 'viewCount' | 'createdAt' | 'updatedAt'
>[]>('/api/blog')
} }
export async function getBlogBySlug(slug: string, password?: string) {
return serverFetch<{
id: string;
title: string;
description: string;
createdAt: string;
content: string;
}>(`/api/blog/${slug}/slug` + (password ? `?p=${password}` : ''));
}

View File

@@ -3,9 +3,12 @@ import { BlogPermission } from "./Blog.Permission.enum";
export interface Blog { export interface Blog {
id: string; id: string;
title: string; title: string;
slug: string;
description: string; description: string;
viewCount: number; viewCount: number;
contentUrl: string; contentUrl: string;
createdAt: string; createdAt: string;
updatedAt: string;
deletedAt: string;
permissions: BlogPermission[]; permissions: BlogPermission[];
} }

View File

@@ -39,8 +39,8 @@
"drawer": "^0.0.2", "drawer": "^0.0.2",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.503.0", "lucide-react": "^0.562.0",
"next": "15.3.1", "next": "16.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pagination": "^0.4.6", "pagination": "^0.4.6",
"popover": "^2.4.1", "popover": "^2.4.1",
@@ -54,22 +54,22 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"select": "^1.1.2", "select": "^1.1.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"swr": "^2.3.7", "swr": "^2.3.8",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"textarea": "^0.3.0", "textarea": "^0.3.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.25.76", "zod": "^4.3.4",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/node": "^20.19.26", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/webappsec-credential-management": "^0.6.9", "@types/webappsec-credential-management": "^0.6.9",
"eslint": "^9.39.1", "eslint": "^9.39.2",
"eslint-config-next": "15.3.1", "eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -19,9 +23,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }