Enhance authentication flow by implementing ProtectedRoute component across various pages, ensuring users are redirected based on their authentication status. Update login page to support Google sign-in and handle redirect URLs after login. Modify analytics and links pages to include loading indicators and protected access. Update next.config.ts to enable middleware for edge functions.

This commit is contained in:
2025-04-23 17:36:54 +08:00
parent c56410b4de
commit 1b4e0bafc7
15 changed files with 597 additions and 85 deletions

View File

@@ -1,5 +1,10 @@
'use client';
import ProtectedRoute from '@/app/components/ProtectedRoute';
export default function HomePage() {
return (
<ProtectedRoute>
<div className="container mx-auto px-4 py-8">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
@@ -61,5 +66,6 @@ export default function HomePage() {
</a>
</div>
</div>
</ProtectedRoute>
);
}

View File

@@ -14,6 +14,7 @@ import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import { TagSelector } from '@/app/components/ui/TagSelector';
import { useSearchParams } from 'next/navigation';
import { useShortUrlStore } from '@/app/utils/store';
import ProtectedRoute from '@/app/components/ProtectedRoute';
// 事件类型定义
interface Event {
@@ -1109,6 +1110,7 @@ function AnalyticsContent() {
// Main page component with Suspense
export default function AnalyticsPage() {
return (
<ProtectedRoute>
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
@@ -1116,5 +1118,6 @@ export default function AnalyticsPage() {
}>
<AnalyticsContent />
</Suspense>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,126 @@
# 数据分析活动接口说明
## 接口概述
`/api/activities` 端点提供了访问和导出分析事件数据的功能,可用于查询短链接的点击和访问记录。
## 请求方式
- HTTP 方法: **GET**
- URL: `/api/activities`
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|-------|------|------|------|
| slug | string | 否* | 短链接标识符 |
| domain | string | 否* | 短链接域名 |
| format | string | 否 | 响应格式,可选值: `csv`纯文本显示不传则默认返回JSON |
| startTime | string | 否* | 起始时间ISO格式 |
| endTime | string | 否* | 结束时间ISO格式 |
| page | number | 否 | 当前页码默认为1 |
| pageSize | number | 否 | 每页记录数默认为50 |
_*注:必须提供 (slug和domain) 或 (startTime和endTime) 中的至少一组过滤条件_
## 响应格式
### JSON格式默认
```json
{
"success": true,
"data": [
{
"id": "事件ID",
"type": "事件类型",
"time": "事件时间",
"visitor": {
"id": "访问者ID",
"ipAddress": "IP地址",
"userAgent": "浏览器用户代理",
"referrer": "来源页面"
},
"device": {
"type": "设备类型",
"browser": "浏览器",
"os": "操作系统"
},
"location": {
"country": "国家",
"city": "城市"
},
"link": {
"id": "链接ID",
"slug": "短链标识",
"originalUrl": "原始链接",
"label": "链接标签",
"tags": ["标签1", "标签2"]
},
"utm": {
"source": "来源",
"medium": "媒介",
"campaign": "活动",
"term": "关键词",
"content": "内容"
}
}
],
"meta": {
"total": ,
"page": ,
"pageSize":
}
}
```
### CSV格式
当使用 `format=csv` 参数时接口将返回以下列的CSV纯文本
```
time,activity,campaign,clientId,originPath
2023-01-01 12:34:56,click,spring_sale,abc123,https://example.com/path?utm_campaign=spring_sale
```
列说明:
- `time`: 事件发生时间
- `activity`: 事件类型(如点击、访问等)
- `campaign`: 营销活动标识从URL中的utm_campaign提取
- `clientId`: 访问者标识的前半部分
- `originPath`: 原始请求路径或引荐URL
## 使用示例
### 基本查询JSON
```
GET /api/activities?slug=0326recap10&domain=example.com
```
### 导出CSV
```
GET /api/activities?slug=0326recap10&format=csv
```
### 按日期范围过滤
```
GET /api/activities?startTime=2023-03-01T00:00:00Z&endTime=2023-03-31T23:59:59Z
```
### 分页
```
GET /api/activities?slug=0326recap10&page=2&pageSize=100
```
## 错误响应
当请求参数不正确或服务器发生错误时,返回以下格式:
```json
{
"success": false,
"error": "错误描述信息"
}
```
常见错误代码:
- 400: 参数错误,例如缺少必要的过滤条件
- 500: 服务器内部错误

View File

@@ -128,16 +128,10 @@ export async function GET(request: NextRequest) {
csvContent += `${time},${activity},${campaign},${clientId},${originPath}\n`;
});
// Generate filename based on available parameters
const filename = slug
? `activities-${slug}.csv`
: `activities-${new Date().toISOString().slice(0,10)}.csv`;
// Return CSV response
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="${filename}"`
'Content-Type': 'text/plain'
}
});
}

View File

@@ -0,0 +1,32 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
// 如果没有code参数则重定向到登录页面
if (!code) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
// 创建supabase客户端
const cookieStore = cookies();
const supabaseRouteHandler = createRouteHandlerClient({ cookies: () => cookieStore });
// 交换code获取会话
await supabaseRouteHandler.auth.exchangeCodeForSession(code);
// 直接重定向到首页,避免中间跳转
return NextResponse.redirect(new URL('/', request.url));
} catch (error) {
console.error('Auth callback error:', error);
// 出错时重定向到登录页面
return NextResponse.redirect(
new URL('/login?message=Authentication failed. Please try again.', request.url)
);
}
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, isLoading } = useAuth();
useEffect(() => {
// 如果非加载状态且用户未登录,重定向到登录页
if (!isLoading && !user) {
console.log('ProtectedRoute: 未登录,重定向到登录页');
// 保存当前URL以便登录后可以返回
const currentUrl = window.location.href;
router.push(`/login?redirect=${encodeURIComponent(currentUrl)}`);
}
}, [isLoading, user, router]);
// 如果正在加载,显示加载指示器
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">...</p>
</div>
</div>
);
}
// 如果用户未登录,不渲染任何内容(等待重定向)
if (!user) {
console.log('ProtectedRoute: 用户未登录,等待重定向');
return null;
}
// 用户已登录,渲染子组件
console.log('ProtectedRoute: 用户已登录,渲染内容');
return <>{children}</>;
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { ProtectedRoute } from '@/lib/auth';
import ProtectedRoute from '@/app/components/ProtectedRoute';
import { limqRequest } from '@/lib/api';
import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';

89
app/debug/page.tsx Normal file
View File

@@ -0,0 +1,89 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/lib/auth';
import supabase from '@/lib/supabase';
export default function DebugPage() {
const { user, session, isLoading } = useAuth();
const [cookies, setCookies] = useState<Record<string, string>>({});
const [rawCookies, setRawCookies] = useState('');
useEffect(() => {
// 获取所有cookie
const allCookies = document.cookie.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.trim().split('=');
if (key) acc[key] = value || '';
return acc;
}, {} as Record<string, string>);
setCookies(allCookies);
setRawCookies(document.cookie);
// 测试supabase会话
const testSession = async () => {
const { data, error } = await supabase.auth.getSession();
console.log('Debug page - Supabase session:', data);
if (error) console.error('Debug page - Session error:', error);
};
testSession();
}, []);
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6"></h1>
<div className="bg-gray-100 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="space-y-2">
<p>: {isLoading ? '加载中...' : '已加载'}</p>
<p>: {user ? '是' : '否'}</p>
<p>: {user?.email || '未登录'}</p>
<p>ID: {user?.id || '未登录'}</p>
<p>: {session ? '是' : '否'}</p>
</div>
</div>
<div className="bg-gray-100 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">Cookies </h2>
<div className="space-y-2">
<p className="text-sm mb-2">Cookie字符串:</p>
<pre className="bg-gray-200 p-4 rounded overflow-x-auto text-xs">
{rawCookies || '(empty)'}
</pre>
<p className="text-sm mt-4 mb-2">Cookies:</p>
<pre className="bg-gray-200 p-4 rounded overflow-x-auto text-xs">
{JSON.stringify(cookies, null, 2) || '{}'}
</pre>
<p className="text-sm mt-4 mb-2">Supabase相关Cookies:</p>
<div className="space-y-1">
<p>sb-access-token: {cookies['sb-access-token'] ? '存在' : '不存在'}</p>
<p>sb-refresh-token: {cookies['sb-refresh-token'] ? '存在' : '不存在'}</p>
<p>supabase-auth-token: {cookies['supabase-auth-token'] ? '存在' : '不存在'}</p>
</div>
</div>
</div>
<div className="flex space-x-4">
<button
onClick={() => window.location.href = '/login'}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
</button>
<button
onClick={async () => {
await supabase.auth.signOut();
window.location.reload();
}}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
</button>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Loader2, ExternalLink, Search } from 'lucide-react';
import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { useRouter } from 'next/navigation';
import { useShortUrlStore, ShortUrlData } from '@/app/utils/store';
import ProtectedRoute from '@/app/components/ProtectedRoute';
// Define attribute type to avoid using 'any'
interface LinkAttributes {
@@ -102,6 +103,17 @@ const convertClickHouseToShortLink = (data: Record<string, unknown>): ShortLink
};
export default function LinksPage() {
return (
<ProtectedRoute>
<div className="container p-6 mx-auto">
<h1 className="mb-6 text-2xl font-bold"></h1>
<LinksPageContent />
</div>
</ProtectedRoute>
);
}
function LinksPageContent() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [links, setLinks] = useState<ShortLink[]>([]);

View File

@@ -21,19 +21,35 @@ function MessageHandler({ setMessage }: { setMessage: (message: { type: string,
export default function LoginPage() {
const router = useRouter();
const { signIn, user } = useAuth();
const searchParams = useSearchParams();
const { signIn, signInWithGoogle, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', content: '' });
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
// 如果用户已登录,重定向到首页
// 获取重定向URL
useEffect(() => {
if (searchParams) {
const redirect = searchParams.get('redirect');
if (redirect) {
setRedirectUrl(decodeURIComponent(redirect));
}
}
}, [searchParams]);
// 如果用户已登录,重定向到原始页面或首页
useEffect(() => {
if (user) {
if (redirectUrl) {
router.push(redirectUrl);
} else {
router.push('/');
}
}, [user, router]);
}
}, [user, router, redirectUrl]);
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();
@@ -53,7 +69,7 @@ export default function LoginPage() {
const { error } = await signIn(email, password);
if (error) {
throw new Error(error.message);
throw new Error(error instanceof Error ? error.message : 'Unknown error');
}
// 登录成功后,会通过 useEffect 重定向
@@ -67,6 +83,28 @@ export default function LoginPage() {
}
};
const handleGoogleSignIn = async () => {
try {
setIsLoading(true);
setMessage({ type: '', content: '' });
const { error } = await signInWithGoogle();
if (error) {
throw new Error(error instanceof Error ? error.message : 'Unknown error');
}
// Google OAuth will redirect the user
} catch (error) {
console.error('Google login error:', error);
setMessage({
type: 'error',
content: error instanceof Error ? error.message : 'Failed to sign in with Google'
});
setIsLoading(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100">
{/* Wrap the component using useSearchParams in Suspense */}
@@ -149,6 +187,33 @@ export default function LoginPage() {
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6">
<button
onClick={handleGoogleSignIn}
disabled={isLoading}
className="w-full flex items-center justify-center gap-3 py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</button>
</div>
</div>
<p className="text-sm mt-6 text-gray-600">
Don&apos;t have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">

View File

@@ -1,5 +1,32 @@
import { redirect } from 'next/navigation';
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
export default function Home() {
redirect('/analytics');
const router = useRouter();
const { user, isLoading } = useAuth();
useEffect(() => {
if (!isLoading) {
if (user) {
// 已登录用户重定向到分析页面
router.push('/analytics');
} else {
// 未登录用户重定向到登录页面
router.push('/login');
}
}
}, [user, isLoading, router]);
// 显示加载指示器
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700">...</p>
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Session, User } from '@supabase/supabase-js';
import supabase from './supabase';
import { limqRequest } from './api';
// 定义用户类型
export type AuthUser = User | null;
@@ -44,6 +43,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return;
}
// 打印会话信息,帮助调试
console.log('Supabase session loaded:', session ? 'Found' : 'Not found');
if (session) {
console.log('User authenticated:', session.user.email);
if (session.expires_at) {
console.log('Session expires at:', new Date(session.expires_at * 1000).toLocaleString());
}
}
setSession(session);
setUser(session?.user || null);
} catch (error) {
@@ -57,6 +65,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
// 监听验证状态变化
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
console.log('Auth state changed, event:', _event);
console.log('New session:', session ? 'Valid' : 'None');
setSession(session);
setUser(session?.user || null);
});
@@ -106,6 +116,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
}
},
});

69
middleware.ts Normal file
View File

@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 获取请求的路径
const path = request.nextUrl.pathname;
console.log(`[Middleware] 请求路径: ${path}`);
// 定义不需要验证的路径
const publicPaths = ['/login', '/register', '/auth/callback'];
// API 路由不需要验证
if (path.startsWith('/api/')) {
console.log('[Middleware] API路由跳过验证');
return NextResponse.next();
}
// 静态资源不需要验证
if (path.includes('/_next/') || path.includes('/static/') || path.match(/\.(ico|png|jpg|jpeg|svg|css|js)$/)) {
console.log('[Middleware] 静态资源,跳过验证');
return NextResponse.next();
}
// 检查是否是公开路径
const isPublicPath = publicPaths.some(publicPath => path === publicPath || path.startsWith(publicPath));
console.log(`[Middleware] 是公开路径: ${isPublicPath}`);
// 获取所有 cookie
const allCookies = Object.fromEntries(request.cookies.getAll().map(c => [c.name, c.value]));
console.log('[Middleware] 所有Cookie:', JSON.stringify(allCookies));
// 检查用户是否登录
const supabaseCookie = request.cookies.get('sb-access-token') ||
request.cookies.get('sb-refresh-token') ||
request.cookies.get('sb-provider-token') ||
request.cookies.get('supabase-auth-token');
const isLoggedIn = !!supabaseCookie;
console.log(`[Middleware] 用户是否登录: ${isLoggedIn}`);
// 如果是公开路径但已登录,重定向到首页
if (isPublicPath && isLoggedIn) {
console.log('[Middleware] 已登录用户访问公开路径,重定向到首页');
return NextResponse.redirect(new URL('/', request.url));
}
// 如果不是公开路径且未登录,重定向到登录页
if (!isPublicPath && !isLoggedIn) {
console.log('[Middleware] 未登录用户访问私有路径,重定向到登录页');
const redirectUrl = new URL('/login', request.url);
redirectUrl.searchParams.set('redirect', encodeURIComponent(request.url));
return NextResponse.redirect(redirectUrl);
}
console.log('[Middleware] 通过验证,允许访问');
return NextResponse.next();
}
// 配置中间件匹配的路径
export const config = {
matcher: [
// 匹配所有路径,但排除静态资源
'/((?!_next/static|_next/image|favicon.ico).*)',
// 明确包括重要的路由
'/',
'/analytics',
'/links',
'/create-shorturl',
],
};

View File

@@ -6,8 +6,13 @@ const nextConfig: NextConfig = {
// 配置实验性选项
experimental: {
// 禁用外部目录处理避免monorepo问题
// externalDir: true,
// 启用边缘函数中间件
instrumentationHook: true,
// 配置中间件匹配
middleware: {
// 确保匹配所有路径
matchAll: '/((?!_next|static|api|public).*)',
},
},
// 禁用严格模式,避免开发时重复渲染

28
pnpm-lock.yaml generated
View File

@@ -237,67 +237,79 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -366,24 +378,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.2.3':
resolution: {integrity: sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.2.3':
resolution: {integrity: sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.2.3':
resolution: {integrity: sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.2.3':
resolution: {integrity: sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==}
@@ -1164,24 +1180,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.0.15':
resolution: {integrity: sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.0.15':
resolution: {integrity: sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.0.15':
resolution: {integrity: sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-win32-arm64-msvc@4.0.15':
resolution: {integrity: sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==}
@@ -1350,21 +1370,25 @@ packages:
resolution: {integrity: sha512-fp4Azi8kHz6TX8SFmKfyScZrMLfp++uRm2srpqRjsRZIIBzH74NtSkdEUHImR4G7f7XJ+sVZjCc6KDDK04YEpQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/rspack-resolver-binding-linux-arm64-musl@1.2.2':
resolution: {integrity: sha512-gMiG3DCFioJxdGBzhlL86KcFgt9HGz0iDhw0YVYPsShItpN5pqIkNrI+L/Q/0gfDiGrfcE0X3VANSYIPmqEAlQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/rspack-resolver-binding-linux-x64-gnu@1.2.2':
resolution: {integrity: sha512-n/4n2CxaUF9tcaJxEaZm+lqvaw2gflfWQ1R9I7WQgYkKEKbRKbpG/R3hopYdUmLSRI4xaW1Cy0Bz40eS2Yi4Sw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/rspack-resolver-binding-linux-x64-musl@1.2.2':
resolution: {integrity: sha512-cHyhAr6rlYYbon1L2Ag449YCj3p6XMfcYTP0AQX+KkQo025d1y/VFtPWvjMhuEsE2lLvtHm7GdJozj6BOMtzVg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/rspack-resolver-binding-wasm32-wasi@1.2.2':
resolution: {integrity: sha512-eogDKuICghDLGc32FtP+WniG38IB1RcGOGz0G3z8406dUdjJvxfHGuGs/dSlM9YEp/v0lEqhJ4mBu6X2nL9pog==}
@@ -2301,24 +2325,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.29.2:
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.29.2:
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.29.2:
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.29.2:
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}