+
Channel Restricted
+
+ This channel has been restricted by a moderator and is no longer available for viewing.
+
+ {restrictionExpiresAt && (
+
+ Restriction lifts: {format(new Date(restrictionExpiresAt), 'PPP p')}
+
+ )}
+
+
+ {isRefreshing ? 'Checking...' : 'Check again'}
+
+
+ );
+ }
return (
diff --git a/apps/web/src/components/app/NavBar/NavBar.tsx b/apps/web/src/components/app/NavBar/NavBar.tsx
index 508070a..1992008 100644
--- a/apps/web/src/components/app/NavBar/NavBar.tsx
+++ b/apps/web/src/components/app/NavBar/NavBar.tsx
@@ -15,7 +15,7 @@ import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
-import { IdCard, Slack } from 'lucide-react';
+import { IdCard, Shield } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
export default function Navbar(props: Props) {
@@ -57,6 +57,17 @@ export default function Navbar(props: Props) {
Bot accounts
+ {user.isAdmin && (
+ <>
+
+
+
+
+ Admin Panel
+
+
+ >
+ )}
API Docs
diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..a623682
--- /dev/null
+++ b/apps/web/src/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps
& {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "bg-popover absolute inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-[--cell-size] select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-muted-foreground select-none text-[0.8rem]",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "bg-accent rounded-l-md",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+ span]:text-xs [&>span]:opacity-70",
+ defaultClassNames.day,
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Calendar, CalendarDayButton }
diff --git a/apps/web/src/lib/auth/abac.ts b/apps/web/src/lib/auth/abac.ts
new file mode 100644
index 0000000..2980906
--- /dev/null
+++ b/apps/web/src/lib/auth/abac.ts
@@ -0,0 +1,121 @@
+import { prisma } from '@hctv/db';
+
+export type Resource = 'channel' | 'bot' | 'streamInfo';
+export type Action = 'read' | 'update' | 'delete' | 'manage';
+
+type User = { id: string };
+
+type ChannelWithRelations = {
+ ownerId: string;
+ managers?: { id: string }[];
+ personalFor?: { id: string } | null;
+};
+
+type BotWithRelations = {
+ ownerId: string;
+};
+
+type PolicyContext = {
+ channel?: ChannelWithRelations;
+ bot?: BotWithRelations;
+};
+
+const policies: Record boolean>> = {
+ channel: {
+ read: () => true,
+ update: (user, { channel }) => {
+ if (!channel) return false;
+ return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
+ },
+ delete: (user, { channel }) => {
+ if (!channel) return false;
+ if (channel.personalFor) return false;
+ return channel.ownerId === user.id;
+ },
+ manage: (user, { channel }) => {
+ if (!channel) return false;
+ return channel.ownerId === user.id;
+ },
+ },
+ bot: {
+ read: () => true,
+ update: (user, { bot }) => {
+ if (!bot) return false;
+ return bot.ownerId === user.id;
+ },
+ delete: (user, { bot }) => {
+ if (!bot) return false;
+ return bot.ownerId === user.id;
+ },
+ manage: (user, { bot }) => {
+ if (!bot) return false;
+ return bot.ownerId === user.id;
+ },
+ },
+ streamInfo: {
+ read: () => true,
+ update: (user, { channel }) => {
+ if (!channel) return false;
+ return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
+ },
+ delete: () => false,
+ manage: (user, { channel }) => {
+ if (!channel) return false;
+ return channel.ownerId === user.id;
+ },
+ },
+};
+
+export function can(user: User, action: Action, resource: Resource, context: PolicyContext): boolean {
+ const policy = policies[resource]?.[action];
+ if (!policy) return false;
+ return policy(user, context);
+}
+
+export async function canAccessChannel(
+ user: User,
+ action: Action,
+ channelId: string
+): Promise {
+ const channel = await prisma.channel.findUnique({
+ where: { id: channelId },
+ include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
+ });
+ if (!channel) return false;
+ return can(user, action, 'channel', { channel });
+}
+
+export async function canAccessChannelByName(
+ user: User,
+ action: Action,
+ channelName: string
+): Promise {
+ const channel = await prisma.channel.findUnique({
+ where: { name: channelName },
+ include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
+ });
+ if (!channel) return false;
+ return can(user, action, 'channel', { channel });
+}
+
+export async function canAccessBot(user: User, action: Action, botId: string): Promise {
+ const bot = await prisma.botAccount.findUnique({
+ where: { id: botId },
+ select: { ownerId: true },
+ });
+ if (!bot) return false;
+ return can(user, action, 'bot', { bot });
+}
+
+export async function canAccessBotBySlug(
+ user: User,
+ action: Action,
+ slug: string
+): Promise {
+ const bot = await prisma.botAccount.findUnique({
+ where: { slug },
+ select: { ownerId: true },
+ });
+ if (!bot) return false;
+ return can(user, action, 'bot', { bot });
+}
diff --git a/apps/web/src/lib/form/actions.ts b/apps/web/src/lib/form/actions.ts
index 801c454..ed41033 100644
--- a/apps/web/src/lib/form/actions.ts
+++ b/apps/web/src/lib/form/actions.ts
@@ -18,6 +18,7 @@ import {
resolveStreamInfo,
resolveUserFromPersonalChannelName,
} from '../auth/resolve';
+import { can, canAccessBot } from '../auth/abac';
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
import { generateStreamKey } from '../db/streamKey';
@@ -42,9 +43,7 @@ export async function editStreamInfo(prev: any, formData: FormData) {
return { success: false, error: 'Channel not found' };
}
- const isBroadcaster =
- channelInfo.ownerId === user.id || channelInfo.managers.some((m) => m.id === user.id);
- if (!isBroadcaster) {
+ if (!can(user, 'update', 'streamInfo', { channel: channelInfo })) {
return { success: false, error: 'Unauthorized' };
}
@@ -202,10 +201,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
return { success: false, error: 'Channel not found' };
}
- const isOwner = channel.ownerId === user.id;
- const isManager = channel.managers.some((manager) => manager.id === user.id);
-
- if (!isOwner && !isManager) {
+ if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
@@ -242,7 +238,7 @@ export async function addChannelManager(channelId: string, userChannel: string)
return { success: false, error: 'Channel not found OR is personal.' };
}
- if (channel.ownerId !== user.id) {
+ if (!can(user, 'manage', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can add managers' };
}
@@ -286,7 +282,7 @@ export async function removeChannelManager(channelId: string, userId: string) {
return { success: false, error: 'Channel not found' };
}
- if (channel.ownerId !== user.id) {
+ if (!can(user, 'manage', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can remove managers' };
}
@@ -355,12 +351,8 @@ export async function deleteChannel(channelId: string) {
return { success: false, error: 'Channel not found' };
}
- if (channel.ownerId !== user.id) {
- return { success: false, error: 'Only channel owners can delete channels' };
- }
-
- if (channel.personalFor) {
- return { success: false, error: 'Cannot delete personal channels' };
+ if (!can(user, 'delete', 'channel', { channel })) {
+ return { success: false, error: 'Only channel owners can delete channels (personal channels cannot be deleted)' };
}
await prisma.channel.delete({
@@ -416,7 +408,7 @@ export async function editBot(prev: any, formData: FormData) {
if (!bot) {
return { success: false, error: 'Bot not found' };
}
- if (bot.ownerId !== user.id) {
+ if (!can(user, 'update', 'bot', { bot })) {
return { success: false, error: 'Unauthorized' };
}
if (bot.slug !== zod.data.slug) {
diff --git a/apps/web/src/lib/providers/StreamInfoProvider.tsx b/apps/web/src/lib/providers/StreamInfoProvider.tsx
index 246f00f..b1e0e0b 100644
--- a/apps/web/src/lib/providers/StreamInfoProvider.tsx
+++ b/apps/web/src/lib/providers/StreamInfoProvider.tsx
@@ -37,4 +37,9 @@ export function useStreams() {
return context
}
-export type StreamInfoResponse = (StreamInfo & { channel: Channel })[]
\ No newline at end of file
+export type StreamInfoResponse = (StreamInfo & {
+ channel: Channel & {
+ isRestricted?: boolean;
+ restrictionExpiresAt?: string | null;
+ };
+})[]
\ No newline at end of file
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index 6de64e7..0e722b5 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -31,6 +31,7 @@ export const lucia = new Lucia(adapter, {
pfpUrl: attributes.pfpUrl,
hasOnboarded: attributes.hasOnboarded,
personalChannelId: attributes.personalChannelId,
+ isAdmin: attributes.isAdmin,
};
},
});
@@ -48,4 +49,5 @@ interface DatabaseUserAttributes {
pfpUrl: string;
hasOnboarded: boolean;
personalChannelId: string | null;
+ isAdmin: boolean;
}
diff --git a/packages/db/prisma/migrations/20251231160608_is_admin/migration.sql b/packages/db/prisma/migrations/20251231160608_is_admin/migration.sql
new file mode 100644
index 0000000..7db3f39
--- /dev/null
+++ b/packages/db/prisma/migrations/20251231160608_is_admin/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/db/prisma/migrations/20251231210756_moderation_stuff/migration.sql b/packages/db/prisma/migrations/20251231210756_moderation_stuff/migration.sql
new file mode 100644
index 0000000..dbb82ef
--- /dev/null
+++ b/packages/db/prisma/migrations/20251231210756_moderation_stuff/migration.sql
@@ -0,0 +1,41 @@
+-- CreateTable
+CREATE TABLE "UserBan" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "reason" TEXT NOT NULL,
+ "bannedBy" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expiresAt" TIMESTAMP(3),
+
+ CONSTRAINT "UserBan_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ChannelRestriction" (
+ "id" TEXT NOT NULL,
+ "channelId" TEXT NOT NULL,
+ "reason" TEXT NOT NULL,
+ "restrictedBy" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expiresAt" TIMESTAMP(3),
+
+ CONSTRAINT "ChannelRestriction_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "UserBan_userId_key" ON "UserBan"("userId");
+
+-- CreateIndex
+CREATE INDEX "UserBan_userId_idx" ON "UserBan"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ChannelRestriction_channelId_key" ON "ChannelRestriction"("channelId");
+
+-- CreateIndex
+CREATE INDEX "ChannelRestriction_channelId_idx" ON "ChannelRestriction"("channelId");
+
+-- AddForeignKey
+ALTER TABLE "UserBan" ADD CONSTRAINT "UserBan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ChannelRestriction" ADD CONSTRAINT "ChannelRestriction_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml
index 648c57f..044d57c 100644
--- a/packages/db/prisma/migrations/migration_lock.toml
+++ b/packages/db/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
-provider = "postgresql"
\ No newline at end of file
+provider = "postgresql"
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 340ff2e..c30000d 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -17,11 +17,13 @@ datasource db {
}
model User {
- id String @id @default(cuid())
- slack_id String
- email String?
- pfpUrl String
+ id String @id @default(cuid())
+ slack_id String
+ email String?
+ pfpUrl String
+
hasOnboarded Boolean @default(false)
+ isAdmin Boolean @default(false)
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
personalChannelId String? @unique
@@ -32,6 +34,7 @@ model User {
streams StreamInfo[]
followers Follow[] @relation("UserFollows")
botAccounts BotAccount[]
+ ban UserBan?
@@index([personalChannelId])
}
@@ -55,6 +58,7 @@ model Channel {
streamKey StreamKey?
obsChatGrantToken String @unique @default(cuid())
is247 Boolean @default(false)
+ restriction ChannelRestriction?
@@index([ownerId])
}
@@ -113,12 +117,12 @@ model StreamKey {
}
model BotAccount {
- id String @id @default(cuid())
+ id String @id @default(cuid())
displayName String
- slug String @unique
- description String @default("A hctv bot account")
+ slug String @unique
+ description String @default("A hctv bot account")
pfpUrl String
- owner User @relation(fields: [ownerId], references: [id])
+ owner User @relation(fields: [ownerId], references: [id])
ownerId String
apiKeys BotApiKey[]
@@ -139,3 +143,27 @@ model BotApiKey {
@@index([botAccountId])
}
+
+model UserBan {
+ id String @id @default(cuid())
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String @unique
+ reason String
+ bannedBy String
+ createdAt DateTime @default(now())
+ expiresAt DateTime?
+
+ @@index([userId])
+}
+
+model ChannelRestriction {
+ id String @id @default(cuid())
+ channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
+ channelId String @unique
+ reason String
+ restrictedBy String
+ createdAt DateTime @default(now())
+ expiresAt DateTime?
+
+ @@index([channelId])
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d6c4357..b80ac4e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -171,6 +171,9 @@ importers:
cmdk:
specifier: 1.0.0
version: 1.0.0(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
hls-video-element:
specifier: ^1.5.0
version: 1.5.10
@@ -204,6 +207,9 @@ importers:
react:
specifier: ^19.2.3
version: 19.2.3
+ react-day-picker:
+ specifier: ^9.13.0
+ version: 9.13.0(react@19.2.3)
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
@@ -615,6 +621,9 @@ packages:
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
+ '@date-fns/tz@1.4.1':
+ resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
+
'@effect/platform@0.90.3':
resolution: {integrity: sha512-XvQ37yzWQKih4Du2CYladd1i/MzqtgkTPNCaN6Ku6No4CK83hDtXIV/rP03nEoBg2R3Pqgz6gGWmE2id2G81HA==}
peerDependencies:
@@ -4355,6 +4364,12 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
+ date-fns-jalali@4.1.0-0:
+ resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
+
+ date-fns@4.1.0:
+ resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
@@ -6609,6 +6624,12 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
+ react-day-picker@9.13.0:
+ resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: '>=16.8.0'
+
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
@@ -8469,6 +8490,8 @@ snapshots:
'@ctrl/tinycolor@4.2.0': {}
+ '@date-fns/tz@1.4.1': {}
+
'@effect/platform@0.90.3(effect@3.17.7)':
dependencies:
'@opentelemetry/semantic-conventions': 1.38.0
@@ -12689,6 +12712,10 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
+ date-fns-jalali@4.1.0-0: {}
+
+ date-fns@4.1.0: {}
+
dayjs@1.11.19: {}
debug@3.2.7:
@@ -15637,6 +15664,13 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
+ react-day-picker@9.13.0(react@19.2.3):
+ dependencies:
+ '@date-fns/tz': 1.4.1
+ date-fns: 4.1.0
+ date-fns-jalali: 4.1.0-0
+ react: 19.2.3
+
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3