@@ -104,6 +256,7 @@ function PostsContent() {
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-3 py-3 group"
+ onClick={() => trackPostView(post.id)}
>
@@ -148,6 +301,7 @@ function PostsContent() {
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline underline-offset-4 line-clamp-1 font-medium"
+ onClick={() => trackPostView(post.id)}
>
{post.title}
@@ -180,6 +334,7 @@ function PostsContent() {
href={post.url}
target="_blank"
rel="noopener noreferrer"
+ onClick={() => trackPostView(post.id)}
>
diff --git a/packages/web/src/app/(user)/profile/edit/page.tsx b/packages/web/src/app/(user)/profile/edit/page.tsx
index 0dd6385..9744b03 100644
--- a/packages/web/src/app/(user)/profile/edit/page.tsx
+++ b/packages/web/src/app/(user)/profile/edit/page.tsx
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { AvatarUpload } from '@/components/avatar-upload';
+import { Switch } from '@/components/ui/switch';
import { PART_OPTIONS } from '@/lib/part-config';
import { PageLoading } from '@/components/ui/page-state';
@@ -39,6 +40,7 @@ interface ProfileData {
bio: string | null;
interests: string[] | null;
resolution: string | null;
+ rssConsent: boolean;
onboardingCompleted: boolean;
githubUrl: string | null;
linkedinUrl: string | null;
@@ -66,6 +68,7 @@ export default function ProfileEditPage() {
const [githubUrl, setGithubUrl] = useState('');
const [linkedinUrl, setLinkedinUrl] = useState('');
const [instagramUrl, setInstagramUrl] = useState('');
+ const [rssConsent, setRssConsent] = useState(true);
useEffect(() => {
const fetchProfile = async () => {
@@ -102,6 +105,7 @@ export default function ProfileEditPage() {
if (data.member.githubUrl) setGithubUrl(data.member.githubUrl);
if (data.member.linkedinUrl) setLinkedinUrl(data.member.linkedinUrl);
if (data.member.instagramUrl) setInstagramUrl(data.member.instagramUrl);
+ if (data.member.rssConsent !== undefined && data.member.rssConsent !== null) setRssConsent(data.member.rssConsent);
} catch (err) {
console.error(err);
router.push('/profile');
@@ -145,6 +149,7 @@ export default function ProfileEditPage() {
githubUrl: githubUrl || null,
linkedinUrl: linkedinUrl || null,
instagramUrl: instagramUrl || null,
+ rssConsent,
}),
});
@@ -370,6 +375,38 @@ export default function ProfileEditPage() {
+ {/* RSS 설정 */}
+
+
+
+
+ RSS 설정
+
+
+ 블로그 글 자동 수집 설정을 관리하세요.
+
+
+
+
+
+
+ {!rssConsent && (
+
+ RSS 수집을 비활성화하면 글을 직접 등록해야 합니다.
+
+ )}
+
+
+
+
+
+
{/* Social Links */}
diff --git a/packages/web/src/app/(user)/profile/onboarding/page.tsx b/packages/web/src/app/(user)/profile/onboarding/page.tsx
index 132a5e4..ae43f72 100644
--- a/packages/web/src/app/(user)/profile/onboarding/page.tsx
+++ b/packages/web/src/app/(user)/profile/onboarding/page.tsx
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { AvatarUpload } from '@/components/avatar-upload';
import { Separator } from '@/components/ui/separator';
+import { Switch } from '@/components/ui/switch';
import { PART_OPTIONS } from '@/lib/part-config';
import { INTEREST_OPTIONS } from '@blog-study/shared/config';
import { PageLoading } from '@/components/ui/page-state';
@@ -37,6 +38,7 @@ export default function OnboardingPage() {
const [githubUrl, setGithubUrl] = useState('');
const [linkedinUrl, setLinkedinUrl] = useState('');
const [instagramUrl, setInstagramUrl] = useState('');
+ const [rssConsent, setRssConsent] = useState(true);
useEffect(() => {
const fetchUserInfo = async () => {
@@ -115,6 +117,7 @@ export default function OnboardingPage() {
githubUrl: githubUrl.trim() || null,
linkedinUrl: linkedinUrl.trim() || null,
instagramUrl: instagramUrl.trim() || null,
+ rssConsent,
}),
});
@@ -260,6 +263,24 @@ export default function OnboardingPage() {
+
+
+
+ {!rssConsent && (
+
+ RSS 수집을 비활성화하면 글을 직접 등록해야 합니다.
+
+ )}
+
+
+
+
diff --git a/packages/web/src/app/api/admin/members/route.ts b/packages/web/src/app/api/admin/members/route.ts
index 2405ebb..cf5345f 100644
--- a/packages/web/src/app/api/admin/members/route.ts
+++ b/packages/web/src/app/api/admin/members/route.ts
@@ -84,6 +84,7 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => {
rssUrl: member.rssUrl,
profileImageUrl: member.profileImageUrl,
bio: member.bio,
+ rssConsent: member.rssConsent ?? true,
status: member.status,
dormantUsed: member.dormantUsed,
dormantStartRound: member.dormantStartRound,
diff --git a/packages/web/src/app/api/admin/scores/route.ts b/packages/web/src/app/api/admin/scores/route.ts
index c4f6915..5516151 100644
--- a/packages/web/src/app/api/admin/scores/route.ts
+++ b/packages/web/src/app/api/admin/scores/route.ts
@@ -78,3 +78,40 @@ export const POST = withAdminAuth(async (request: NextRequest) => {
return errorResponse(error);
}
});
+
+/**
+ * DELETE /api/admin/scores
+ * 점수 내역 삭제 (관리자 전용)
+ */
+export const DELETE = withAdminAuth(async (request: NextRequest) => {
+ try {
+ const body = await request.json();
+ const { scoreId } = body;
+
+ if (!scoreId || typeof scoreId !== 'string' || !UUID_REGEX.test(scoreId)) {
+ throw Errors.badRequest('유효하지 않은 scoreId입니다.');
+ }
+
+ const database = db();
+
+ // 점수 레코드 조회
+ const [record] = await database
+ .select()
+ .from(activityScores)
+ .where(eq(activityScores.id, scoreId))
+ .limit(1);
+
+ if (!record) {
+ throw Errors.notFound('점수 내역을 찾을 수 없습니다.');
+ }
+
+ // 점수 레코드 삭제
+ await database
+ .delete(activityScores)
+ .where(eq(activityScores.id, scoreId));
+
+ return successResponse({ deleted: scoreId }, '점수 내역이 삭제되었습니다.');
+ } catch (error) {
+ return errorResponse(error);
+ }
+});
diff --git a/packages/web/src/app/api/posts/[id]/view/route.ts b/packages/web/src/app/api/posts/[id]/view/route.ts
new file mode 100644
index 0000000..b01f0ff
--- /dev/null
+++ b/packages/web/src/app/api/posts/[id]/view/route.ts
@@ -0,0 +1,116 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { eq, sql } from 'drizzle-orm';
+import { db } from '@/lib/db';
+import { db as sharedDb } from '@blog-study/shared';
+import { createClient } from '@/lib/supabase/server';
+
+const { posts, members, activityScores, ActivityScoreType } = sharedDb;
+
+const POST_VIEW_POINTS = 2;
+const POST_VIEW_DAILY_CAP = 10;
+
+function getTodayDateString(): string {
+ const now = new Date();
+ const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
+ return kst.toISOString().split('T')[0]!;
+}
+
+/**
+ * POST /api/posts/[id]/view
+ * 글 조회 시 활동 점수 부여
+ * - 본인 글 제외
+ * - 같은 글 중복 조회 불가 (post_views UNIQUE)
+ * - 하루 최대 5회 (10점)
+ */
+export async function POST(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id: postId } = await params;
+ const supabase = await createClient();
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+
+ if (authError || !user) {
+ return NextResponse.json({ scored: false }, { status: 401 });
+ }
+
+ const discordIdentity = user.identities?.find(
+ (identity) => identity.provider === 'discord'
+ );
+ const discordId = discordIdentity?.id;
+ if (!discordId) {
+ return NextResponse.json({ scored: false }, { status: 400 });
+ }
+
+ const database = db();
+
+ const [member] = await database
+ .select({ id: members.id })
+ .from(members)
+ .where(eq(members.discordId, discordId))
+ .limit(1);
+
+ if (!member) {
+ return NextResponse.json({ scored: false }, { status: 404 });
+ }
+
+ // 포스트 존재 확인 + 작성자 체크
+ const [post] = await database
+ .select({ id: posts.id, memberId: posts.memberId, title: posts.title })
+ .from(posts)
+ .where(eq(posts.id, postId))
+ .limit(1);
+
+ if (!post) {
+ return NextResponse.json({ scored: false }, { status: 404 });
+ }
+
+ // 본인 글 제외
+ if (post.memberId === member.id) {
+ return NextResponse.json({ scored: false, reason: 'own_post' });
+ }
+
+ // 중복 조회 체크 + 삽입 (INSERT ON CONFLICT DO NOTHING)
+ const insertResult = await database.execute(sql`
+ INSERT INTO post_views (id, member_id, post_id)
+ VALUES (gen_random_uuid(), ${member.id}, ${postId})
+ ON CONFLICT (member_id, post_id) DO NOTHING
+ RETURNING id
+ `);
+
+ if (insertResult.length === 0) {
+ return NextResponse.json({ scored: false, reason: 'already_viewed' });
+ }
+
+ // 일일 상한 체크 후 점수 부여 (원자적 CTE)
+ const today = getTodayDateString();
+ const safeTitle = post.title.replace(/[<>"'&]/g, '').slice(0, 200);
+ const scoreResult = await database.execute(sql`
+ WITH daily AS (
+ SELECT COALESCE(SUM(${activityScores.points}), 0) AS total
+ FROM ${activityScores}
+ WHERE ${activityScores.memberId} = ${member.id}
+ AND ${activityScores.type} = ${ActivityScoreType.POST_VIEW}
+ AND ${activityScores.date} = ${today}
+ )
+ INSERT INTO activity_scores (id, member_id, type, points, description, date)
+ SELECT gen_random_uuid(), ${member.id}, ${ActivityScoreType.POST_VIEW}, ${POST_VIEW_POINTS},
+ ${`글 조회: ${safeTitle}`}, ${today}
+ FROM daily
+ WHERE daily.total < ${POST_VIEW_DAILY_CAP}
+ RETURNING points
+ `);
+
+ const scored = scoreResult.length > 0;
+
+ return NextResponse.json({
+ scored,
+ points: scored ? POST_VIEW_POINTS : 0,
+ reason: scored ? 'success' : 'daily_cap',
+ });
+ } catch (error) {
+ console.error('Post view API error:', error);
+ return NextResponse.json({ scored: false }, { status: 500 });
+ }
+}
diff --git a/packages/web/src/app/api/posts/manual/route.ts b/packages/web/src/app/api/posts/manual/route.ts
new file mode 100644
index 0000000..dd398b2
--- /dev/null
+++ b/packages/web/src/app/api/posts/manual/route.ts
@@ -0,0 +1,179 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { eq, sql } from 'drizzle-orm';
+import { db } from '@/lib/db';
+import { db as sharedDb } from '@blog-study/shared';
+import { createClient } from '@/lib/supabase/server';
+
+const { posts, members, rounds, ActivityScoreType } = sharedDb;
+
+const BLOG_POST_POINTS = 30;
+const BLOG_POST_DAILY_CAP = 60;
+
+function getTodayDateString(): string {
+ const now = new Date();
+ const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
+ return kst.toISOString().split('T')[0]!;
+}
+
+/**
+ * OG 태그에서 제목과 발행일 추출
+ */
+async function fetchOgData(url: string): Promise<{ title: string | null; publishedAt: string | null }> {
+ try {
+ const response = await fetch(url, {
+ headers: { 'User-Agent': 'BlogStudyBot/1.0' },
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!response.ok) return { title: null, publishedAt: null };
+
+ const html = await response.text();
+
+ // title: og:title >
+ const ogTitleMatch = html.match(/]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i)
+ || html.match(/]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i);
+ const titleMatch = html.match(/]*>([^<]+)<\/title>/i);
+ const title = ogTitleMatch?.[1] || titleMatch?.[1] || null;
+
+ // publishedAt: article:published_time
+ const pubMatch = html.match(/]*property=["']article:published_time["'][^>]*content=["']([^"']+)["']/i)
+ || html.match(/]*content=["']([^"']+)["'][^>]*property=["']article:published_time["']/i);
+ const publishedAt = pubMatch?.[1] || null;
+
+ return { title: title?.trim() || null, publishedAt };
+ } catch {
+ return { title: null, publishedAt: null };
+ }
+}
+
+/**
+ * POST /api/posts/manual
+ * 수동 글 등록 (OG 크롤링 → 실패 시 제목 직접 입력)
+ */
+export async function POST(request: NextRequest) {
+ try {
+ const supabase = await createClient();
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+
+ if (authError || !user) {
+ return NextResponse.json({ message: '인증이 필요합니다.' }, { status: 401 });
+ }
+
+ const discordIdentity = user.identities?.find(
+ (identity) => identity.provider === 'discord'
+ );
+ const discordId = discordIdentity?.id;
+ if (!discordId) {
+ return NextResponse.json({ message: 'Discord 계정이 필요합니다.' }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const { url, title: manualTitle } = body;
+
+ if (!url || typeof url !== 'string') {
+ return NextResponse.json({ message: 'URL은 필수입니다.' }, { status: 400 });
+ }
+
+ // URL 형식 검증
+ try {
+ const parsed = new URL(url);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return NextResponse.json({ message: 'http 또는 https URL만 허용됩니다.' }, { status: 400 });
+ }
+ } catch {
+ return NextResponse.json({ message: '유효하지 않은 URL입니다.' }, { status: 400 });
+ }
+
+ const database = db();
+
+ // 멤버 조회
+ const [member] = await database
+ .select({ id: members.id })
+ .from(members)
+ .where(eq(members.discordId, discordId))
+ .limit(1);
+
+ if (!member) {
+ return NextResponse.json({ message: '멤버 정보를 찾을 수 없습니다.' }, { status: 404 });
+ }
+
+ // 중복 URL 체크
+ const [existing] = await database
+ .select({ id: posts.id })
+ .from(posts)
+ .where(eq(posts.url, url))
+ .limit(1);
+
+ if (existing) {
+ return NextResponse.json({ message: '이미 등록된 URL입니다.' }, { status: 409 });
+ }
+
+ // OG 크롤링 시도
+ let title = manualTitle as string | null;
+ let publishedAt: Date = new Date();
+
+ if (!title) {
+ const ogData = await fetchOgData(url);
+ if (ogData.title) {
+ title = ogData.title;
+ if (ogData.publishedAt) {
+ publishedAt = new Date(ogData.publishedAt);
+ }
+ }
+ }
+
+ // 크롤링 실패 + 수동 제목 없음
+ if (!title) {
+ return NextResponse.json(
+ { message: '제목을 자동으로 가져올 수 없습니다. 직접 입력해주세요.', needsTitle: true },
+ { status: 422 }
+ );
+ }
+
+ // 현재 회차 조회
+ const [currentRound] = await database
+ .select({ id: rounds.id })
+ .from(rounds)
+ .where(eq(rounds.isCurrent, true))
+ .limit(1);
+
+ // 포스트 등록
+ const [newPost] = await database
+ .insert(posts)
+ .values({
+ memberId: member.id,
+ roundId: currentRound?.id ?? null,
+ title,
+ url,
+ publishedAt,
+ })
+ .returning();
+
+ // 블로그 포스트 점수 부여 (30점, 일일 60점 상한)
+ const today = getTodayDateString();
+ const safeTitle = title.replace(/[<>"'&]/g, '').slice(0, 200);
+ await database.execute(sql`
+ WITH daily AS (
+ SELECT COALESCE(SUM(points), 0) AS total
+ FROM activity_scores
+ WHERE member_id = ${member.id}
+ AND type = ${ActivityScoreType.BLOG_POST}
+ AND date = ${today}
+ )
+ INSERT INTO activity_scores (id, member_id, type, points, description, date)
+ SELECT gen_random_uuid(), ${member.id}, ${ActivityScoreType.BLOG_POST}, ${BLOG_POST_POINTS},
+ ${`블로그 포스트: ${safeTitle}`}, ${today}
+ FROM daily
+ WHERE daily.total < ${BLOG_POST_DAILY_CAP}
+ RETURNING points
+ `);
+
+ return NextResponse.json({
+ message: '글이 등록되었습니다.',
+ post: newPost,
+ });
+ } catch (error) {
+ console.error('Manual post API error:', error);
+ return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 });
+ }
+}
diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts
index 06f1cbb..a9e5c9a 100644
--- a/packages/web/src/app/api/profile/edit/route.ts
+++ b/packages/web/src/app/api/profile/edit/route.ts
@@ -48,7 +48,7 @@ export async function PUT(request: NextRequest) {
}
const body = await request.json();
- const { name, nickname, part, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl } = body;
+ const { name, nickname, part, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl, rssConsent } = body;
if (part && (typeof part !== 'string' || part.length > 50)) {
return NextResponse.json(
@@ -123,6 +123,7 @@ export async function PUT(request: NextRequest) {
githubUrl: githubUrl || null,
linkedinUrl: linkedinUrl || null,
instagramUrl: instagramUrl || null,
+ ...(typeof rssConsent === 'boolean' ? { rssConsent } : {}),
updatedAt: new Date(),
})
.where(eq(members.id, memberData.id));
diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts
index 19009db..576ec96 100644
--- a/packages/web/src/app/api/profile/onboarding/route.ts
+++ b/packages/web/src/app/api/profile/onboarding/route.ts
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
- const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl } = body;
+ const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl, rssConsent } = body;
// 필수 필드 검증
if (!name || typeof name !== 'string' || name.trim().length === 0) {
@@ -163,6 +163,7 @@ export async function POST(request: NextRequest) {
githubUrl: githubUrl || null,
linkedinUrl: linkedinUrl || null,
instagramUrl: instagramUrl || null,
+ rssConsent: rssConsent !== false,
onboardingCompleted: true,
updatedAt: new Date(),
})
@@ -185,6 +186,7 @@ export async function POST(request: NextRequest) {
githubUrl: githubUrl || null,
linkedinUrl: linkedinUrl || null,
instagramUrl: instagramUrl || null,
+ rssConsent: rssConsent !== false,
onboardingCompleted: true,
status: 'active',
});
diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts
index a442e5b..fa12659 100644
--- a/packages/web/src/app/api/profile/route.ts
+++ b/packages/web/src/app/api/profile/route.ts
@@ -102,6 +102,7 @@ export async function GET() {
bio: memberData.bio,
interests: memberData.interests,
resolution: memberData.resolution,
+ rssConsent: memberData.rssConsent ?? true,
onboardingCompleted: memberData.onboardingCompleted,
status: memberData.status,
dormantUsed: memberData.dormantUsed,
diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx
new file mode 100644
index 0000000..5f4117f
--- /dev/null
+++ b/packages/web/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b65e2d..aefc0cf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -146,6 +146,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.6
+ version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1562,6 +1565,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-switch@1.2.6':
+ resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
+ peerDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
@@ -5589,6 +5605,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3