refactor: eslint and prettier conflict will be resolved and formatted

This commit is contained in:
Enes Köksal
2026-04-23 17:05:43 +03:00
parent 385b61f29b
commit 3cfea40fbd
219 changed files with 16638 additions and 13663 deletions
+4
View File
@@ -0,0 +1,4 @@
.next/
node_modules/
prisma/migrations/
bun.lock
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"semi": true, "semi": true,
"singleQuote": false, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"useTabs": false, "useTabs": false,
"trailingComma": "es5", "trailingComma": "es5",
+5
View File
@@ -1,15 +1,18 @@
# AGENTS.md # AGENTS.md
## Must-follow constraints ## Must-follow constraints
- Use `bun` only. Do not use `npm` or `pnpm`. - Use `bun` only. Do not use `npm` or `pnpm`.
- Do not start the dev server (`bun run dev`); assume it is already running. - Do not start the dev server (`bun run dev`); assume it is already running.
- If you change `prisma/schema.prisma`, run `bun run db:generate`. - If you change `prisma/schema.prisma`, run `bun run db:generate`.
- In App Router dynamic routes, keep `params` typed as `Promise<...>` and `await params` in handlers/pages. - In App Router dynamic routes, keep `params` typed as `Promise<...>` and `await params` in handlers/pages.
## Validation before finishing ## Validation before finishing
- Run `bun run check`. - Run `bun run check`.
## Repo-specific conventions ## Repo-specific conventions
- Use `auth()` from `@/lib/auth` for server-side session reads. - Use `auth()` from `@/lib/auth` for server-side session reads.
- Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks. - Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks.
- For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`. - For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`.
@@ -17,10 +20,12 @@
- In Prisma raw SQL, use `$executeRaw` for statements that return no rows (e.g. `pg_advisory_xact_lock`). Using `$queryRaw` on void-returning functions causes a Prisma deserialization error (`Failed to deserialize column of type 'void'`). - In Prisma raw SQL, use `$executeRaw` for statements that return no rows (e.g. `pg_advisory_xact_lock`). Using `$queryRaw` on void-returning functions causes a Prisma deserialization error (`Failed to deserialize column of type 'void'`).
## Important locations ## Important locations
- Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`. - Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`.
- Shared API response helpers: `lib/api-response.ts`. - Shared API response helpers: `lib/api-response.ts`.
- Auth + access-control helpers: `lib/auth.ts`. - Auth + access-control helpers: `lib/auth.ts`.
## Change safety rules ## Change safety rules
- Prefer backward-compatible API changes unless explicitly asked to break contracts. - Prefer backward-compatible API changes unless explicitly asked to break contracts.
- For multi-step DB writes, use Prisma transactions. - For multi-step DB writes, use Prisma transactions.
+1 -5
View File
@@ -1,11 +1,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
export default async function AuthLayout({ export default async function AuthLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const session = await auth(); const session = await auth();
// If already logged in, redirect to dashboard // If already logged in, redirect to dashboard
+10 -3
View File
@@ -27,7 +27,8 @@ const ERROR_MESSAGES: Record<string, string> = {
// Generic message — avoid confirming whether a credentials account exists for this email // Generic message — avoid confirming whether a credentials account exists for this email
OAuthAccountNotLinked: 'Sign-in failed. Please try a different method or contact support.', OAuthAccountNotLinked: 'Sign-in failed. Please try a different method or contact support.',
OAuthCallbackError: 'OAuth sign-in failed. Please try again.', OAuthCallbackError: 'OAuth sign-in failed. Please try again.',
OAuthEmailNotVerified: 'Your OAuth account email is not verified. Please verify it with your provider and try again.', OAuthEmailNotVerified:
'Your OAuth account email is not verified. Please verify it with your provider and try again.',
InvalidVerificationToken: 'The verification link is invalid or has expired.', InvalidVerificationToken: 'The verification link is invalid or has expired.',
VerificationFailed: 'Email verification failed. Please try again.', VerificationFailed: 'Email verification failed. Please try again.',
Default: 'Something went wrong. Please try again.', Default: 'Something went wrong. Please try again.',
@@ -109,7 +110,8 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
<CardContent> <CardContent>
{showSuccess && ( {showSuccess && (
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4"> <div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
Account created successfully! Please check your email to verify your address before signing in. Account created successfully! Please check your email to verify your address before
signing in.
</div> </div>
)} )}
@@ -172,7 +174,12 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
{oauthLoading === 'github' ? ( {oauthLoading === 'github' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : ( ) : (
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"> <svg
className="h-4 w-4 mr-2"
viewBox="0 0 24 24"
aria-hidden="true"
fill="currentColor"
>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" /> <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg> </svg>
)} )}
+9 -10
View File
@@ -4,12 +4,8 @@ import { LoginForm, LoginFormSkeleton } from './login-form';
import { Suspense } from 'react'; import { Suspense } from 'react';
export default function LoginPage() { export default function LoginPage() {
const googleEnabled = Boolean( const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET, const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
);
const githubEnabled = Boolean(
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET,
);
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
@@ -26,12 +22,15 @@ export default function LoginPage() {
<p className="text-center text-xs text-muted-foreground mt-4"> <p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our{' '} By continuing, you agree to our{' '}
<Link href="/terms" className="underline hover:text-foreground">Terms of Service</Link> <Link href="/terms" className="underline hover:text-foreground">
{' '}and{' '} Terms of Service
<Link href="/privacy" className="underline hover:text-foreground">Privacy Policy</Link> </Link>{' '}
and{' '}
<Link href="/privacy" className="underline hover:text-foreground">
Privacy Policy
</Link>
</p> </p>
</div> </div>
</div> </div>
); );
} }
+2 -6
View File
@@ -2,12 +2,8 @@ import { isInviteCodeRequired } from '@/lib/feature-flags';
import RegisterPageClient from './register-page-client'; import RegisterPageClient from './register-page-client';
export default function RegisterPage() { export default function RegisterPage() {
const googleEnabled = Boolean( const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET, const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
);
const githubEnabled = Boolean(
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET,
);
return ( return (
<RegisterPageClient <RegisterPageClient
+35 -12
View File
@@ -16,7 +16,11 @@ interface RegisterPageClientProps {
githubEnabled: boolean; githubEnabled: boolean;
} }
export default function RegisterPageClient({ requireInviteCode, googleEnabled, githubEnabled }: RegisterPageClientProps) { export default function RegisterPageClient({
requireInviteCode,
googleEnabled,
githubEnabled,
}: RegisterPageClientProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]); const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
@@ -122,9 +126,7 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
<UserPlus className="h-5 w-5" /> <UserPlus className="h-5 w-5" />
Create Account Create Account
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Join OpenFrame to collaborate on video projects</CardDescription>
Join OpenFrame to collaborate on video projects
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* OAuth Buttons */} {/* OAuth Buttons */}
@@ -142,10 +144,22 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : ( ) : (
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true"> <svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true">
<path 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" fill="#4285F4" /> <path
<path 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" fill="#34A853" /> 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 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" fill="#FBBC05" /> fill="#4285F4"
<path 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" fill="#EA4335" /> />
<path
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"
fill="#34A853"
/>
<path
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"
fill="#FBBC05"
/>
<path
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"
fill="#EA4335"
/>
</svg> </svg>
)} )}
Continue with Google Continue with Google
@@ -162,7 +176,12 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
{oauthLoading === 'github' ? ( {oauthLoading === 'github' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : ( ) : (
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"> <svg
className="h-4 w-4 mr-2"
viewBox="0 0 24 24"
aria-hidden="true"
fill="currentColor"
>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" /> <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg> </svg>
)} )}
@@ -297,9 +316,13 @@ export default function RegisterPageClient({ requireInviteCode, googleEnabled, g
<p className="text-center text-xs text-muted-foreground mt-4"> <p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our{' '} By continuing, you agree to our{' '}
<a href="/terms" className="underline hover:text-foreground">Terms of Service</a> <a href="/terms" className="underline hover:text-foreground">
{' '}and{' '} Terms of Service
<a href="/privacy" className="underline hover:text-foreground">Privacy Policy</a> </a>{' '}
and{' '}
<a href="/privacy" className="underline hover:text-foreground">
Privacy Policy
</a>
</p> </p>
</div> </div>
</div> </div>
+5 -3
View File
@@ -55,8 +55,8 @@ function VerifyEmailContent() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
We sent a verification link to{' '} We sent a verification link to{' '}
{emailParam ? <strong>{emailParam}</strong> : 'your email address'}. {emailParam ? <strong>{emailParam}</strong> : 'your email address'}. Click the link to
Click the link to activate your account. activate your account.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -75,7 +75,9 @@ function VerifyEmailContent() {
<span className="w-full border-t" /> <span className="w-full border-t" />
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">Didn&apos;t receive it?</span> <span className="bg-card px-2 text-muted-foreground">
Didn&apos;t receive it?
</span>
</div> </div>
</div> </div>
+4 -4
View File
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardHeader, CardContent } from "@/components/ui/card" import { Card, CardHeader, CardContent } from '@/components/ui/card';
function ProjectCardSkeleton() { function ProjectCardSkeleton() {
return ( return (
@@ -24,7 +24,7 @@ function ProjectCardSkeleton() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
export default function DashboardLoading() { export default function DashboardLoading() {
@@ -44,5 +44,5 @@ export default function DashboardLoading() {
))} ))}
</div> </div>
</div> </div>
) );
} }
+12 -12
View File
@@ -2,7 +2,10 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access'; import {
hasCollaboratorBillingBackedAccess,
requireBillingAccessOrRedirect,
} from '@/lib/route-access';
import { DashboardClient } from './dashboard-client'; import { DashboardClient } from './dashboard-client';
import { buildBillingAccessWhereInput } from '@/lib/billing'; import { buildBillingAccessWhereInput } from '@/lib/billing';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags'; import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
@@ -10,7 +13,7 @@ import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
export default async function DashboardPage({ export default async function DashboardPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ ws?: string; sort?: string; page?: string }> searchParams: Promise<{ ws?: string; sort?: string; page?: string }>;
}) { }) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -46,10 +49,7 @@ export default async function DashboardPage({
{ {
workspace: { workspace: {
owner: buildBillingAccessWhereInput(), owner: buildBillingAccessWhereInput(),
OR: [ OR: [{ ownerId: session.user.id }, { members: { some: { userId: session.user.id } } }],
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
],
}, },
}, },
], ],
@@ -63,10 +63,10 @@ export default async function DashboardPage({
where: baseWhere, where: baseWhere,
select: { select: {
workspace: { workspace: {
select: { id: true, name: true } select: { id: true, name: true },
}
}, },
distinct: ['workspaceId'] },
distinct: ['workspaceId'],
}); });
const [creatableWorkspaces, editableProject] = await Promise.all([ const [creatableWorkspaces, editableProject] = await Promise.all([
@@ -110,7 +110,7 @@ export default async function DashboardPage({
// Final query constraints // Final query constraints
const queryWhere: Prisma.ProjectWhereInput = { const queryWhere: Prisma.ProjectWhereInput = {
...baseWhere, ...baseWhere,
...(ws && ws !== 'all' ? { workspaceId: ws } : {}) ...(ws && ws !== 'all' ? { workspaceId: ws } : {}),
}; };
const [projects, totalProjects] = await Promise.all([ const [projects, totalProjects] = await Promise.all([
@@ -132,8 +132,8 @@ export default async function DashboardPage({
orderBy: { updatedAt: orderByDirection }, orderBy: { updatedAt: orderByDirection },
}), }),
db.project.count({ db.project.count({
where: queryWhere where: queryWhere,
}) }),
]); ]);
const totalPages = Math.ceil(totalProjects / pageSize); const totalPages = Math.ceil(totalProjects / pageSize);
+19 -3
View File
@@ -3,7 +3,18 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, FolderOpen, Clock, Users, Globe, Lock, UserPlus, Building2, ArrowUp, ArrowDown } from 'lucide-react'; import {
Plus,
FolderOpen,
Clock,
Users,
Globe,
Lock,
UserPlus,
Building2,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -62,13 +73,18 @@ function VisibilityIcon({ visibility }: { visibility: string }) {
type SortOrder = 'desc' | 'asc'; type SortOrder = 'desc' | 'asc';
export function ProjectFilter({ projects, workspaces, totalPages, canCreateProjects }: ProjectFilterProps) { export function ProjectFilter({
projects,
workspaces,
totalPages,
canCreateProjects,
}: ProjectFilterProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const selectedWorkspace = searchParams.get('ws') || 'all'; const selectedWorkspace = searchParams.get('ws') || 'all';
const sortOrder = searchParams.get('sort') as SortOrder || 'desc'; const sortOrder = (searchParams.get('sort') as SortOrder) || 'desc';
const page = Number(searchParams.get('page')) || 1; const page = Number(searchParams.get('page')) || 1;
const createQueryString = useCallback( const createQueryString = useCallback(
+7 -11
View File
@@ -1,8 +1,8 @@
"use client"; 'use client';
import { useEffect } from "react"; import { useEffect } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from 'lucide-react';
export default function DashboardError({ export default function DashboardError({
error, error,
@@ -12,7 +12,7 @@ export default function DashboardError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
console.error("Dashboard error:", error); console.error('Dashboard error:', error);
}, [error]); }, [error]);
return ( return (
@@ -23,17 +23,13 @@ export default function DashboardError({
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
Something went wrong loading the dashboard. Your projects and videos are safe. Something went wrong loading the dashboard. Your projects and videos are safe.
</p> </p>
{error.digest && ( {error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
<p className="text-muted-foreground text-xs">
Error ID: {error.digest}
</p>
)}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={reset} variant="default"> <Button onClick={reset} variant="default">
Try again Try again
</Button> </Button>
<Button onClick={() => window.location.href = "/dashboard"} variant="outline"> <Button onClick={() => (window.location.href = '/dashboard')} variant="outline">
Go to dashboard Go to dashboard
</Button> </Button>
</div> </div>
@@ -10,7 +10,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER'; type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER';
type TabValue = 'feedback' | 'review'; type TabValue = 'feedback' | 'review';
@@ -70,7 +76,10 @@ export default function FeedbackPage() {
for (const file of allowedFiles) { for (const file of allowedFiles) {
const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type); const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type);
if (!isImage) { if (!isImage) {
setStatus({ type: 'error', message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.' }); setStatus({
type: 'error',
message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.',
});
continue; continue;
} }
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
@@ -92,7 +101,9 @@ export default function FeedbackPage() {
const targetUrl = feedbackScreenshotPreviewUrls[index]; const targetUrl = feedbackScreenshotPreviewUrls[index];
if (targetUrl) URL.revokeObjectURL(targetUrl); if (targetUrl) URL.revokeObjectURL(targetUrl);
setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
setFeedbackScreenshotPreviewUrls((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); setFeedbackScreenshotPreviewUrls((prev) =>
prev.filter((_, currentIndex) => currentIndex !== index)
);
}; };
const clearFeedbackScreenshots = () => { const clearFeedbackScreenshots = () => {
@@ -169,7 +180,10 @@ export default function FeedbackPage() {
setReviewMessage(''); setReviewMessage('');
setReviewRating('5'); setReviewRating('5');
setAllowShowcase(false); setAllowShowcase(false);
setStatus({ type: 'success', message: 'Review submitted. Thank you for sharing your experience.' }); setStatus({
type: 'success',
message: 'Review submitted. Thank you for sharing your experience.',
});
} catch { } catch {
setStatus({ type: 'error', message: 'Failed to submit review' }); setStatus({ type: 'error', message: 'Failed to submit review' });
} finally { } finally {
@@ -180,7 +194,10 @@ export default function FeedbackPage() {
return ( return (
<div className="min-h-[calc(100vh-4rem)] px-4 py-10"> <div className="min-h-[calc(100vh-4rem)] px-4 py-10">
<div className="mx-auto w-full max-w-3xl space-y-6"> <div className="mx-auto w-full max-w-3xl space-y-6">
<Link href="/dashboard" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"> <Link
href="/dashboard"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="mr-1 h-4 w-4" /> <ArrowLeft className="mr-1 h-4 w-4" />
Back to Dashboard Back to Dashboard
</Link> </Link>
@@ -189,7 +206,8 @@ export default function FeedbackPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Feedback & Review</CardTitle> <CardTitle className="text-2xl">Feedback & Review</CardTitle>
<CardDescription> <CardDescription>
Send product feedback, report bugs, or share a review we can feature on the landing page. Send product feedback, report bugs, or share a review we can feature on the landing
page.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -205,7 +223,11 @@ export default function FeedbackPage() {
</div> </div>
)} )}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabValue)} className="w-full"> <Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabValue)}
className="w-full"
>
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="feedback" className="gap-1.5"> <TabsTrigger value="feedback" className="gap-1.5">
<Bug className="h-3.5 w-3.5" /> <Bug className="h-3.5 w-3.5" />
@@ -308,7 +330,11 @@ export default function FeedbackPage() {
</div> </div>
<Button type="submit" disabled={isSubmittingFeedback}> <Button type="submit" disabled={isSubmittingFeedback}>
{isSubmittingFeedback ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ImageIcon className="mr-2 h-4 w-4" />} {isSubmittingFeedback ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ImageIcon className="mr-2 h-4 w-4" />
)}
Submit Feedback Submit Feedback
</Button> </Button>
</form> </form>
@@ -332,7 +358,11 @@ export default function FeedbackPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Rating</Label> <Label>Rating</Label>
<Select value={reviewRating} onValueChange={setReviewRating} disabled={isSubmittingReview}> <Select
value={reviewRating}
onValueChange={setReviewRating}
disabled={isSubmittingReview}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -369,11 +399,17 @@ export default function FeedbackPage() {
onChange={(event) => setAllowShowcase(event.target.checked)} onChange={(event) => setAllowShowcase(event.target.checked)}
disabled={isSubmittingReview} disabled={isSubmittingReview}
/> />
<span>I allow OpenFrame to potentially showcase this review on the landing page.</span> <span>
I allow OpenFrame to potentially showcase this review on the landing page.
</span>
</label> </label>
<Button type="submit" disabled={isSubmittingReview}> <Button type="submit" disabled={isSubmittingReview}>
{isSubmittingReview ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquareQuote className="mr-2 h-4 w-4" />} {isSubmittingReview ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MessageSquareQuote className="mr-2 h-4 w-4" />
)}
Submit Review Submit Review
</Button> </Button>
</form> </form>
+1 -5
View File
@@ -2,11 +2,7 @@ import { Header } from '@/components/layout';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { hasAppNavigationAccess } from '@/lib/route-access'; import { hasAppNavigationAccess } from '@/lib/route-access';
export default async function DashboardLayout({ export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const session = await auth(); const session = await auth();
const showAppNavigation = session?.user?.id const showAppNavigation = session?.user?.id
? await hasAppNavigationAccess(session.user.id) ? await hasAppNavigationAccess(session.user.id)
+3 -3
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from 'next/link';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { FileQuestion } from "lucide-react"; import { FileQuestion } from 'lucide-react';
export default function DashboardNotFound() { export default function DashboardNotFound() {
return ( return (
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from '@/components/ui/card';
function VideoCardSkeleton() { function VideoCardSkeleton() {
return ( return (
@@ -14,7 +14,7 @@ function VideoCardSkeleton() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
export default function ProjectLoading() { export default function ProjectLoading() {
@@ -49,5 +49,5 @@ export default function ProjectLoading() {
))} ))}
</div> </div>
</div> </div>
) );
} }
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from 'next/link';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { FolderX } from "lucide-react"; import { FolderX } from 'lucide-react';
export default function ProjectNotFound() { export default function ProjectNotFound() {
return ( return (
@@ -9,7 +9,8 @@ export default function ProjectNotFound() {
<FolderX className="h-12 w-12 text-muted-foreground" /> <FolderX className="h-12 w-12 text-muted-foreground" />
<h1 className="text-2xl font-bold">Project Not Found</h1> <h1 className="text-2xl font-bold">Project Not Found</h1>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
The project you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to it. The project you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to
it.
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
+11 -7
View File
@@ -1,8 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { import { ArrowLeft } from 'lucide-react';
ArrowLeft,
} from 'lucide-react';
import { GuestGate } from '@/components/guest-gate'; import { GuestGate } from '@/components/guest-gate';
import { auth, checkProjectAccess } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@@ -120,8 +118,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
}, },
}), }),
db.video.count({ db.video.count({
where: { projectId: project.id } where: { projectId: project.id },
}) }),
]); ]);
const totalPages = Math.ceil(totalVideos / pageSize); const totalPages = Math.ceil(totalVideos / pageSize);
@@ -132,7 +130,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
return { return {
id: video.id, id: video.id,
title: video.title, title: video.title,
thumbnailUrl: activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail', thumbnailUrl:
activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
currentVersion: video._count.versions, currentVersion: video._count.versions,
commentCount: activeVersion?._count.comments || 0, commentCount: activeVersion?._count.comments || 0,
duration: formatDuration(activeVersion?.duration), duration: formatDuration(activeVersion?.duration),
@@ -141,7 +140,12 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
}; };
}); });
const canEdit = access.canEdit && (isOwner || project.members[0]?.role === 'ADMIN' || workspaceRole === 'OWNER' || workspaceRole === 'ADMIN'); const canEdit =
access.canEdit &&
(isOwner ||
project.members[0]?.role === 'ADMIN' ||
workspaceRole === 'OWNER' ||
workspaceRole === 'ADMIN');
const isAuthenticated = !!session?.user?.id; const isAuthenticated = !!session?.user?.id;
const projectData = { const projectData = {
@@ -57,7 +57,7 @@ export function ProjectContentClient({
canEdit, canEdit,
isOwner, isOwner,
totalPages, totalPages,
currentPage currentPage,
}: ProjectContentClientProps) { }: ProjectContentClientProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -115,7 +115,10 @@ export function ProjectContentClient({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{project.workspace && ( {project.workspace && (
<Link href={`/workspaces/${project.workspace.id}`}> <Link href={`/workspaces/${project.workspace.id}`}>
<Badge variant="secondary" className="flex items-center gap-1 hover:bg-accent transition-colors"> <Badge
variant="secondary"
className="flex items-center gap-1 hover:bg-accent transition-colors"
>
<Building2 className="h-3 w-3" /> <Building2 className="h-3 w-3" />
{project.workspace.name} {project.workspace.name}
</Badge> </Badge>
@@ -221,16 +224,13 @@ export function ProjectContentClient({
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="mt-8 flex items-center justify-end space-x-2"> <div className="mt-8 flex items-center justify-end space-x-2">
<Button <Button variant="outline" size="sm" disabled={currentPage <= 1} asChild={currentPage > 1}>
variant="outline"
size="sm"
disabled={currentPage <= 1}
asChild={currentPage > 1}
>
{currentPage > 1 ? ( {currentPage > 1 ? (
<Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>Previous</Link> <Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>
Previous
</Link>
) : ( ) : (
"Previous" 'Previous'
)} )}
</Button> </Button>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
@@ -245,7 +245,7 @@ export function ProjectContentClient({
{currentPage < totalPages ? ( {currentPage < totalPages ? (
<Link href={`?${createQueryString('page', (currentPage + 1).toString())}`}>Next</Link> <Link href={`?${createQueryString('page', (currentPage + 1).toString())}`}>Next</Link>
) : ( ) : (
"Next" 'Next'
)} )}
</Button> </Button>
</div> </div>
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardHeader, CardContent } from "@/components/ui/card" import { Card, CardHeader, CardContent } from '@/components/ui/card';
export default function ProjectSettingsLoading() { export default function ProjectSettingsLoading() {
return ( return (
@@ -63,5 +63,5 @@ export default function ProjectSettingsLoading() {
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
@@ -3,7 +3,20 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Loader2, Globe, Lock, UserPlus, Trash2, AlertTriangle, Settings, Save, Tag, Plus, X } from 'lucide-react'; import {
ArrowLeft,
Loader2,
Globe,
Lock,
UserPlus,
Trash2,
AlertTriangle,
Settings,
Save,
Tag,
Plus,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -23,7 +36,12 @@ import {
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC'; type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [ const visibilityOptions: {
value: Visibility;
label: string;
description: string;
icon: React.ReactNode;
}[] = [
{ {
value: 'PRIVATE', value: 'PRIVATE',
label: 'Private', label: 'Private',
@@ -105,7 +123,9 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
setTags(data.data); setTags(data.data);
} }
}) })
.catch(() => { /* Silent fail - tags are optional */ }); .catch(() => {
/* Silent fail - tags are optional */
});
}, [projectId]); }, [projectId]);
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
@@ -262,7 +282,7 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
<Input <Input
id="name" id="name"
value={formData.name} value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required required
disabled={isSaving} disabled={isSaving}
className="h-11" className="h-11"
@@ -276,7 +296,9 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
<Textarea <Textarea
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
rows={3} rows={3}
disabled={isSaving} disabled={isSaving}
className="resize-none" className="resize-none"
@@ -290,29 +312,36 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setFormData(prev => ({ ...prev, visibility: option.value }))} onClick={() =>
setFormData((prev) => ({ ...prev, visibility: option.value }))
}
disabled={isSaving} disabled={isSaving}
className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${formData.visibility === option.value className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${
formData.visibility === option.value
? 'border-primary bg-primary/5 ring-1 ring-primary/20' ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-border/80 hover:bg-accent/50' : 'border-border hover:border-border/80 hover:bg-accent/50'
}`} }`}
> >
<div className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${formData.visibility === option.value <div
className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
formData.visibility === option.value
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
}`}> }`}
>
{option.icon} {option.icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium">{option.label}</div> <div className="font-medium">{option.label}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">{option.description}</div>
{option.description}
</div> </div>
</div> <div
<div className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${formData.visibility === option.value className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${
formData.visibility === option.value
? 'border-primary bg-primary' ? 'border-primary bg-primary'
: 'border-muted-foreground/30' : 'border-muted-foreground/30'
}`}> }`}
>
{formData.visibility === option.value && ( {formData.visibility === option.value && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" /> <div className="w-2 h-2 rounded-full bg-primary-foreground" />
)} )}
@@ -350,15 +379,16 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
<Tag className="h-5 w-5" /> <Tag className="h-5 w-5" />
Comment Tags Comment Tags
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Customize tags for categorizing comments on videos</CardDescription>
Customize tags for categorizing comments on videos
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Existing tags */} {/* Existing tags */}
<div className="space-y-2"> <div className="space-y-2">
{tags.map((tag) => ( {tags.map((tag) => (
<div key={tag.id} className="flex flex-wrap items-center gap-2 p-2 rounded-lg border bg-card"> <div
key={tag.id}
className="flex flex-wrap items-center gap-2 p-2 rounded-lg border bg-card"
>
{editingTagId === tag.id ? ( {editingTagId === tag.id ? (
<> <>
<input <input
@@ -427,8 +457,16 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
className="flex-1 h-8" className="flex-1 h-8"
onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} onKeyDown={(e) => e.key === 'Enter' && handleAddTag()}
/> />
<Button size="sm" onClick={handleAddTag} disabled={!newTagName.trim() || isAddingTag}> <Button
{isAddingTag ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />} size="sm"
onClick={handleAddTag}
disabled={!newTagName.trim() || isAddingTag}
>
{isAddingTag ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -449,9 +487,7 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
<div className="flex items-center justify-between p-4 rounded-xl border border-destructive/20 bg-destructive/5"> <div className="flex items-center justify-between p-4 rounded-xl border border-destructive/20 bg-destructive/5">
<div> <div>
<h4 className="font-medium">Delete this project</h4> <h4 className="font-medium">Delete this project</h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">This action cannot be undone</p>
This action cannot be undone
</p>
</div> </div>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@@ -466,12 +502,13 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
<AlertDialogDescription asChild> <AlertDialogDescription asChild>
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
This will permanently delete this project and all of its This will permanently delete this project and all of its videos,
videos, versions, and comments. This action cannot be undone. versions, and comments. This action cannot be undone.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="delete-confirm"> <Label htmlFor="delete-confirm">
Type <strong className="text-foreground">{formData.name}</strong> to confirm Type <strong className="text-foreground">{formData.name}</strong> to
confirm
</Label> </Label>
<Input <Input
id="delete-confirm" id="delete-confirm"
@@ -2,7 +2,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Copy, Check, Loader2, UserPlus, Share2, Globe, Lock, Mail, X } from 'lucide-react'; import {
ArrowLeft,
Copy,
Check,
Loader2,
UserPlus,
Share2,
Globe,
Lock,
Mail,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -75,7 +86,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
try { try {
// TODO: Implement invite API // TODO: Implement invite API
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
setInviteSuccess(`Invitation sent to ${inviteEmail}`); setInviteSuccess(`Invitation sent to ${inviteEmail}`);
setInviteEmail(''); setInviteEmail('');
setTimeout(() => setInviteSuccess(''), 3000); setTimeout(() => setInviteSuccess(''), 3000);
@@ -181,9 +192,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
<Mail className="h-5 w-5 text-primary" /> <Mail className="h-5 w-5 text-primary" />
Invite People Invite People
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Send email invitations to specific people</CardDescription>
Send email invitations to specific people
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<form onSubmit={handleInvite} className="flex gap-2"> <form onSubmit={handleInvite} className="flex gap-2">
@@ -195,7 +204,11 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
className="h-11 flex-1" className="h-11 flex-1"
disabled={isInviting} disabled={isInviting}
/> />
<Button type="submit" disabled={isInviting || !inviteEmail.trim()} className="h-11"> <Button
type="submit"
disabled={isInviting || !inviteEmail.trim()}
className="h-11"
>
{isInviting ? ( {isInviting ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
@@ -242,7 +255,11 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
<Badge variant="secondary" className="text-xs capitalize"> <Badge variant="secondary" className="text-xs capitalize">
{member.role.toLowerCase()} {member.role.toLowerCase()}
</Badge> </Badge>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -256,7 +273,9 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
<div className="text-center py-6 text-muted-foreground"> <div className="text-center py-6 text-muted-foreground">
<UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" /> <UserPlus className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No members yet</p> <p className="text-sm">No members yet</p>
<p className="text-xs opacity-70">Invite people to collaborate on this project</p> <p className="text-xs opacity-70">
Invite people to collaborate on this project
</p>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -271,9 +290,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
<Globe className="h-5 w-5 text-primary" /> <Globe className="h-5 w-5 text-primary" />
Public Link Public Link
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Share this link with anyone</CardDescription>
Share this link with anyone
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -288,11 +305,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
className="h-11 w-11 shrink-0" className="h-11 w-11 shrink-0"
onClick={() => copyToClipboard(getDirectLink())} onClick={() => copyToClipboard(getDirectLink())}
> >
{copied ? ( {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -312,9 +325,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
Only you can access this project. Change visibility to share with others. Only you can access this project. Change visibility to share with others.
</p> </p>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/projects/${projectId}/settings`}> <Link href={`/projects/${projectId}/settings`}>Change Visibility</Link>
Change Visibility
</Link>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -100,7 +100,13 @@ const isSafeUrl = (url: string) => {
} }
}; };
export default function CompareVersionsPageClient({ projectId, videoId }: { projectId: string; videoId: string }) { export default function CompareVersionsPageClient({
projectId,
videoId,
}: {
projectId: string;
videoId: string;
}) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [video, setVideo] = useState<VideoData | null>(null); const [video, setVideo] = useState<VideoData | null>(null);
@@ -164,7 +170,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
useEffect(() => { useEffect(() => {
async function fetchVideo() { async function fetchVideo() {
try { try {
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}?includeComments=false`); const res = await fetch(
`/api/projects/${projectId}/videos/${videoId}?includeComments=false`
);
if (!res.ok) { if (!res.ok) {
setError('Failed to load video'); setError('Failed to load video');
setLoading(false); setLoading(false);
@@ -176,9 +184,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
const versionsParam = searchParams.get('versions'); const versionsParam = searchParams.get('versions');
if (versionsParam) { if (versionsParam) {
const ids = versionsParam.split(',').filter((id) => const ids = versionsParam
data.versions.some((v: Version) => v.id === id) .split(',')
); .filter((id) => data.versions.some((v: Version) => v.id === id));
if (ids.length >= 2) { if (ids.length >= 2) {
setPanelVersionIds(ids); setPanelVersionIds(ids);
} else { } else {
@@ -291,12 +299,23 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
const playing = state === window.YT?.PlayerState?.PLAYING; const playing = state === window.YT?.PlayerState?.PLAYING;
if (playing) { if (playing) {
players.forEach((p) => { try { p.pauseVideo(); } catch { /* */ } }); players.forEach((p) => {
try {
p.pauseVideo();
} catch {
/* */
}
});
setIsPlaying(false); setIsPlaying(false);
} else { } else {
const t = firstPlayer.getCurrentTime(); const t = firstPlayer.getCurrentTime();
players.forEach((p) => { players.forEach((p) => {
try { p.seekTo(t, true); p.playVideo(); } catch { /* */ } try {
p.seekTo(t, true);
p.playVideo();
} catch {
/* */
}
}); });
setIsPlaying(true); setIsPlaying(true);
} }
@@ -307,11 +326,18 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
const handleSeek = useCallback((time: number) => { const handleSeek = useCallback((time: number) => {
const players = Array.from(playersRef.current.values()); const players = Array.from(playersRef.current.values());
players.forEach((p) => { try { p.seekTo(time, true); } catch { /* */ } }); players.forEach((p) => {
try {
p.seekTo(time, true);
} catch {
/* */
}
});
setCurrentTime(time); setCurrentTime(time);
}, []); }, []);
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { const handleTimelineMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!timelineRef.current || durationRef.current <= 0) return; if (!timelineRef.current || durationRef.current <= 0) return;
setIsDragging(true); setIsDragging(true);
const rect = timelineRef.current.getBoundingClientRect(); const rect = timelineRef.current.getBoundingClientRect();
@@ -320,9 +346,12 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
currentTimeRef.current = time; currentTimeRef.current = time;
setCurrentTime(time); setCurrentTime(time);
handleSeek(time); handleSeek(time);
}, [handleSeek]); },
[handleSeek]
);
const handleTimelineMouseMove = useCallback((e: React.MouseEvent) => { const handleTimelineMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging || !timelineRef.current || durationRef.current <= 0) return; if (!isDragging || !timelineRef.current || durationRef.current <= 0) return;
const rect = timelineRef.current.getBoundingClientRect(); const rect = timelineRef.current.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
@@ -336,7 +365,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
if (timecodeRef.current) { if (timecodeRef.current) {
timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`; timecodeRef.current.textContent = `${formatTime(time)} / ${formatTime(durationRef.current)}`;
} }
}, [isDragging]); },
[isDragging]
);
const handleTimelineMouseUp = useCallback(() => { const handleTimelineMouseUp = useCallback(() => {
if (!isDragging) return; if (!isDragging) return;
@@ -399,7 +430,8 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
return;
const players = Array.from(playersRef.current.values()); const players = Array.from(playersRef.current.values());
if (players.length === 0) return; if (players.length === 0) return;
@@ -430,8 +462,14 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
e.preventDefault(); e.preventDefault();
players.forEach((p) => { players.forEach((p) => {
try { try {
if (p.isMuted?.()) { p.unMute?.(); } else { p.mute?.(); } if (p.isMuted?.()) {
} catch { /* */ } p.unMute?.();
} else {
p.mute?.();
}
} catch {
/* */
}
}); });
break; break;
} }
@@ -442,7 +480,8 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
}, [handlePlayPause, handleSeek]); }, [handlePlayPause, handleSeek]);
// Fetch comments for a version // Fetch comments for a version
const toggleComments = useCallback(async (versionId: string) => { const toggleComments = useCallback(
async (versionId: string) => {
if (openCommentsPanel === versionId) { if (openCommentsPanel === versionId) {
setOpenCommentsPanel(null); setOpenCommentsPanel(null);
return; return;
@@ -463,7 +502,9 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
setCommentsLoading(null); setCommentsLoading(null);
} }
} }
}, [openCommentsPanel, commentsCache]); },
[openCommentsPanel, commentsCache]
);
const handleChangeVersion = useCallback((panelIndex: number, newVersionId: string) => { const handleChangeVersion = useCallback((panelIndex: number, newVersionId: string) => {
setPanelVersionIds((prev) => { setPanelVersionIds((prev) => {
@@ -471,7 +512,11 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
const oldId = next[panelIndex]; const oldId = next[panelIndex];
const oldPlayer = playersRef.current.get(oldId); const oldPlayer = playersRef.current.get(oldId);
if (oldPlayer) { if (oldPlayer) {
try { oldPlayer.destroy(); } catch { /* */ } try {
oldPlayer.destroy();
} catch {
/* */
}
playersRef.current.delete(oldId); playersRef.current.delete(oldId);
} }
next[panelIndex] = newVersionId; next[panelIndex] = newVersionId;
@@ -619,11 +664,21 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
if (!player) return; if (!player) return;
const isMuted = mutedPanels.has(versionId); const isMuted = mutedPanels.has(versionId);
try { try {
if (isMuted) { player.unMute(); } else { player.mute(); } if (isMuted) {
} catch { /* */ } player.unMute();
} else {
player.mute();
}
} catch {
/* */
}
setMutedPanels((prev) => { setMutedPanels((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (isMuted) { next.delete(versionId); } else { next.add(versionId); } if (isMuted) {
next.delete(versionId);
} else {
next.add(versionId);
}
return next; return next;
}); });
}} }}
@@ -686,7 +741,11 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
<div <div
className={cn( className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none', 'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
isPlaying ? (cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100') : 'opacity-100' isPlaying
? cursorIdle
? 'opacity-0'
: 'opacity-0 group-hover:opacity-100'
: 'opacity-100'
)} )}
> >
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center"> <div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
@@ -710,7 +769,12 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
{panelComments.length} {panelComments.length}
</Badge> </Badge>
</div> </div>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpenCommentsPanel(null)}> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setOpenCommentsPanel(null)}
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -728,20 +792,29 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
[...panelComments] [...panelComments]
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp)
.map((comment) => { .map((comment) => {
const authorName = comment.author?.name || comment.guestName || 'Anonymous'; const authorName =
comment.author?.name || comment.guestName || 'Anonymous';
return ( return (
<div <div
key={comment.id} key={comment.id}
className={cn('rounded-lg border p-2 text-xs', comment.isResolved && 'opacity-60')} className={cn(
'rounded-lg border p-2 text-xs',
comment.isResolved && 'opacity-60'
)}
> >
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<Avatar className="h-4 w-4"> <Avatar className="h-4 w-4">
<AvatarImage src={comment.author?.image ?? undefined} /> <AvatarImage src={comment.author?.image ?? undefined} />
<AvatarFallback className="text-[8px]">{authorName.charAt(0)}</AvatarFallback> <AvatarFallback className="text-[8px]">
{authorName.charAt(0)}
</AvatarFallback>
</Avatar> </Avatar>
<span className="font-medium truncate">{authorName}</span> <span className="font-medium truncate">{authorName}</span>
<button <button
onClick={(e) => { e.stopPropagation(); handleSeek(comment.timestamp); }} onClick={(e) => {
e.stopPropagation();
handleSeek(comment.timestamp);
}}
className="ml-auto flex items-center gap-0.5 text-primary bg-primary/10 px-1 py-0.5 rounded text-[10px] hover:bg-primary/20 transition-colors" className="ml-auto flex items-center gap-0.5 text-primary bg-primary/10 px-1 py-0.5 rounded text-[10px] hover:bg-primary/20 transition-colors"
> >
<Clock className="h-2.5 w-2.5" /> <Clock className="h-2.5 w-2.5" />
@@ -749,13 +822,18 @@ export default function CompareVersionsPageClient({ projectId, videoId }: { proj
</button> </button>
</div> </div>
{comment.content && ( {comment.content && (
<p className="text-muted-foreground leading-relaxed">{comment.content}</p> <p className="text-muted-foreground leading-relaxed">
{comment.content}
</p>
)} )}
{comment.tag && ( {comment.tag && (
<Badge <Badge
variant="outline" variant="outline"
className="mt-1 text-[10px] px-1.5 py-0" className="mt-1 text-[10px] px-1.5 py-0"
style={{ borderColor: comment.tag.color, color: comment.tag.color }} style={{
borderColor: comment.tag.color,
color: comment.tag.color,
}}
> >
{comment.tag.name} {comment.tag.name}
</Badge> </Badge>
@@ -873,7 +951,11 @@ function YouTubePanel({
clearTimeout(timeout); clearTimeout(timeout);
onUnregister(version.id); onUnregister(version.id);
if (playerRef.current) { if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* */ } try {
playerRef.current.destroy();
} catch {
/* */
}
playerRef.current = null; playerRef.current = null;
} }
}; };
@@ -882,7 +964,11 @@ function YouTubePanel({
return () => { return () => {
onUnregister(version.id); onUnregister(version.id);
if (playerRef.current) { if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* */ } try {
playerRef.current.destroy();
} catch {
/* */
}
playerRef.current = null; playerRef.current = null;
} }
}; };
@@ -979,11 +1065,8 @@ function BunnyPanel({
} }
return cachedDuration; return cachedDuration;
}, },
getPlayerState: () => ( getPlayerState: () =>
isPlaying isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
? (window.YT?.PlayerState?.PLAYING ?? 1)
: (window.YT?.PlayerState?.PAUSED ?? 2)
),
setPlaybackRate: (rate: number) => { setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate; videoEl.playbackRate = rate;
}, },
@@ -997,7 +1080,11 @@ function BunnyPanel({
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('error', onError); videoEl.removeEventListener('error', onError);
if (hlsRef.current) { if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ } try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null; hlsRef.current = null;
} }
videoEl.removeAttribute('src'); videoEl.removeAttribute('src');
@@ -1014,10 +1101,18 @@ function BunnyPanel({
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth); setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
} }
}; };
const onTimeUpdate = () => { cachedTime = videoEl.currentTime || 0; }; const onTimeUpdate = () => {
const onPlay = () => { isPlaying = true; }; cachedTime = videoEl.currentTime || 0;
const onPause = () => { isPlaying = false; }; };
const onEnded = () => { isPlaying = false; }; const onPlay = () => {
isPlaying = true;
};
const onPause = () => {
isPlaying = false;
};
const onEnded = () => {
isPlaying = false;
};
if (!bunnyCdnHostname) { if (!bunnyCdnHostname) {
return; return;
} }
@@ -1027,7 +1122,11 @@ function BunnyPanel({
sourceMode = 'original'; sourceMode = 'original';
clearRetryTimer(); clearRetryTimer();
if (hlsRef.current) { if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ } try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null; hlsRef.current = null;
} }
videoEl.src = getRetryUrl(originalUrl); videoEl.src = getRetryUrl(originalUrl);
@@ -1075,24 +1174,30 @@ function BunnyPanel({
hls.on(Hls.Events.ERROR, (_, data) => { hls.on(Hls.Events.ERROR, (_, data) => {
if (destroyed) return; if (destroyed) return;
const responseCode = (data as { response?: { code?: number } }).response?.code; const responseCode = (data as { response?: { code?: number } }).response?.code;
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR const isManifestLoadFailure =
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT; data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
const hasProcessingLikeStatus = responseCode === undefined data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|| responseCode === 0 const hasProcessingLikeStatus =
|| responseCode === 403 responseCode === undefined ||
|| responseCode === 404 responseCode === 0 ||
|| responseCode === 423 responseCode === 403 ||
|| responseCode === 429 responseCode === 404 ||
|| responseCode === 503; responseCode === 423 ||
responseCode === 429 ||
responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus; const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR const isNetworkPreMetadataProcessing =
&& hasProcessingLikeStatus data.type === Hls.ErrorTypes.NETWORK_ERROR &&
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA; hasProcessingLikeStatus &&
const isUnknownPreMetadataProcessing = !data.details videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
&& !data.type const isUnknownPreMetadataProcessing =
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA; !data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) { if (
isLikelyProcessing ||
isNetworkPreMetadataProcessing ||
isUnknownPreMetadataProcessing
) {
if (sourceMode === 'hls') { if (sourceMode === 'hls') {
activateOriginalFallback(); activateOriginalFallback();
return; return;
@@ -1132,13 +1237,20 @@ function BunnyPanel({
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]); }, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
return ( return (
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black"> <div
ref={panelRef}
className="relative w-full h-full group flex items-center justify-center bg-black"
>
<div <div
className={cn( className={cn(
'relative flex items-center justify-center bg-black', 'relative flex items-center justify-center bg-black',
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full' isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)} )}
style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined} style={
isPortraitSource && portraitFrameWidth > 0
? { width: `${portraitFrameWidth}px` }
: undefined
}
> >
<video <video
ref={videoRef} ref={videoRef}
@@ -1156,5 +1268,5 @@ function BunnyPanel({
/> />
</div> </div>
</div> </div>
) );
} }
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from "@/components/ui/separator" import { Separator } from '@/components/ui/separator';
function PlayerPanelSkeleton() { function PlayerPanelSkeleton() {
return ( return (
@@ -12,7 +12,7 @@ function PlayerPanelSkeleton() {
<Skeleton className="h-4 w-24 mx-auto" /> <Skeleton className="h-4 w-24 mx-auto" />
</div> </div>
</div> </div>
) );
} }
export default function CompareLoading() { export default function CompareLoading() {
@@ -35,5 +35,5 @@ export default function CompareLoading() {
<PlayerPanelSkeleton /> <PlayerPanelSkeleton />
</div> </div>
</div> </div>
) );
} }
@@ -1,8 +1,8 @@
"use client"; 'use client';
import { useEffect } from "react"; import { useEffect } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { AlertTriangle, Film } from "lucide-react"; import { AlertTriangle, Film } from 'lucide-react';
export default function VideoError({ export default function VideoError({
error, error,
@@ -12,7 +12,7 @@ export default function VideoError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
console.error("Video player error:", error); console.error('Video player error:', error);
}, [error]); }, [error]);
return ( return (
@@ -24,13 +24,10 @@ export default function VideoError({
</div> </div>
<h1 className="text-2xl font-bold">Video Player Error</h1> <h1 className="text-2xl font-bold">Video Player Error</h1>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
Something went wrong with the video player. This could be due to a network issue or a problem with the video file. Something went wrong with the video player. This could be due to a network issue or a
problem with the video file.
</p> </p>
{error.digest && ( {error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
<p className="text-muted-foreground text-xs">
Error ID: {error.digest}
</p>
)}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={reset} variant="default"> <Button onClick={reset} variant="default">
@@ -1,8 +1,4 @@
export default function VideoLayout({ export default function VideoLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
// This layout is empty - no header, no sidebar // This layout is empty - no header, no sidebar
// The video page uses full screen space // The video page uses full screen space
return <>{children}</>; return <>{children}</>;
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from "@/components/ui/separator" import { Separator } from '@/components/ui/separator';
function CommentSkeleton() { function CommentSkeleton() {
return ( return (
@@ -14,7 +14,7 @@ function CommentSkeleton() {
<Skeleton className="h-4 w-full mb-1" /> <Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-4 w-2/3" />
</div> </div>
) );
} }
export default function VideoPlayerLoading() { export default function VideoPlayerLoading() {
@@ -80,5 +80,5 @@ export default function VideoPlayerLoading() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from 'next/link';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Film } from "lucide-react"; import { Film } from 'lucide-react';
export default function VideoNotFound() { export default function VideoNotFound() {
return ( return (
@@ -2,7 +2,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Check, Copy, Link2, Loader2, RefreshCcw, ShieldOff, Lock, ShieldCheck } from 'lucide-react'; import {
ArrowLeft,
Check,
Copy,
Link2,
Loader2,
RefreshCcw,
ShieldOff,
Lock,
ShieldCheck,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -46,7 +56,9 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
setError(''); setError('');
try { try {
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { cache: 'no-store' }); const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
cache: 'no-store',
});
const payload = (await response.json()) as ShareResponse; const payload = (await response.json()) as ShareResponse;
if (!response.ok || payload.error) { if (!response.ok || payload.error) {
@@ -152,7 +164,10 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
}), }),
}); });
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null; const payload = (await response.json().catch(() => null)) as
| ShareResponse
| { error?: string }
| null;
if (!response.ok || ('error' in (payload || {}) && payload?.error)) { if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
setError((payload as { error?: string } | null)?.error || 'Failed to update link security'); setError((payload as { error?: string } | null)?.error || 'Failed to update link security');
return; return;
@@ -182,9 +197,14 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowDownloads: nextAllowDownloads }), body: JSON.stringify({ allowDownloads: nextAllowDownloads }),
}); });
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null; const payload = (await response.json().catch(() => null)) as
| ShareResponse
| { error?: string }
| null;
if (!response.ok || ('error' in (payload || {}) && payload?.error)) { if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
setError((payload as { error?: string } | null)?.error || 'Failed to update download setting'); setError(
(payload as { error?: string } | null)?.error || 'Failed to update download setting'
);
return; return;
} }
const data = (payload as ShareResponse).data; const data = (payload as ShareResponse).data;
@@ -237,18 +257,28 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={createShareLink} disabled={submitting} variant="outline"> <Button onClick={createShareLink} disabled={submitting} variant="outline">
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCcw className="h-4 w-4 mr-2" />} {submitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Regenerate Link Regenerate Link
</Button> </Button>
<Button onClick={revokeShareLink} disabled={submitting} variant="destructive"> <Button onClick={revokeShareLink} disabled={submitting} variant="destructive">
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ShieldOff className="h-4 w-4 mr-2" />} {submitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<ShieldOff className="h-4 w-4 mr-2" />
)}
Revoke Link Revoke Link
</Button> </Button>
</div> </div>
<div className="rounded-lg border p-3 space-y-2"> <div className="rounded-lg border p-3 space-y-2">
<div> <div>
<p className="text-sm font-medium">Video download</p> <p className="text-sm font-medium">Video download</p>
<p className="text-xs text-muted-foreground">Allow viewers with this link to download</p> <p className="text-xs text-muted-foreground">
Allow viewers with this link to download
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -270,13 +300,19 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
<div className="rounded-lg border p-3 space-y-2"> <div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
{hasPassword ? <ShieldCheck className="h-4 w-4 text-green-600" /> : <Lock className="h-4 w-4" />} {hasPassword ? (
<ShieldCheck className="h-4 w-4 text-green-600" />
) : (
<Lock className="h-4 w-4" />
)}
Link password Link password
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
type="password" type="password"
placeholder={hasPassword ? 'Enter new password to replace current one' : 'Set a password'} placeholder={
hasPassword ? 'Enter new password to replace current one' : 'Set a password'
}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={submitting} disabled={submitting}
@@ -302,18 +338,21 @@ export default function VideoSharePageClient({ projectId, videoId }: VideoShareP
</div> </div>
) : ( ) : (
<Button onClick={createShareLink} disabled={submitting}> <Button onClick={createShareLink} disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Link2 className="h-4 w-4 mr-2" />} {submitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Link2 className="h-4 w-4 mr-2" />
)}
Create Review Link Create Review Link
</Button> </Button>
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
This link allows guests to leave comments without an account. You can optionally protect it with a password. This link allows guests to leave comments without an account. You can optionally
protect it with a password.
</p> </p>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-destructive">{error}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -4,14 +4,27 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2, UploadCloud, FileVideo } from 'lucide-react'; import {
ArrowLeft,
Loader2,
Link as LinkIcon,
AlertCircle,
CheckCircle2,
UploadCloud,
FileVideo,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers'; import {
parseVideoUrl,
fetchVideoMetadata,
getThumbnailUrl,
type VideoSource,
} from '@/lib/video-providers';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import * as tus from 'tus-js-client'; import * as tus from 'tus-js-client';
@@ -60,7 +73,8 @@ export default function NewVideoPageClient({
description: '', description: '',
}); });
const isUploadingFile = isLoading && uploadMode === 'file'; const isUploadingFile = isLoading && uploadMode === 'file';
const leaveWarningMessage = 'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?'; const leaveWarningMessage =
'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
useEffect(() => { useEffect(() => {
pendingBunnyVideoIdRef.current = pendingBunnyVideoId; pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
@@ -70,7 +84,8 @@ export default function NewVideoPageClient({
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken; pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
}, [pendingBunnyUploadToken]); }, [pendingBunnyUploadToken]);
const cleanupPendingBunnyVideo = useCallback(async (videoId: string, uploadToken: string, keepalive = false) => { const cleanupPendingBunnyVideo = useCallback(
async (videoId: string, uploadToken: string, keepalive = false) => {
try { try {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, { await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE', method: 'DELETE',
@@ -90,9 +105,12 @@ export default function NewVideoPageClient({
setPendingBunnyUploadToken(null); setPendingBunnyUploadToken(null);
} }
} }
}, [projectId]); },
[projectId]
);
const abortAndCleanupPendingUpload = useCallback((keepalive = false) => { const abortAndCleanupPendingUpload = useCallback(
(keepalive = false) => {
const pendingVideoId = pendingBunnyVideoIdRef.current; const pendingVideoId = pendingBunnyVideoIdRef.current;
const pendingUploadToken = pendingBunnyUploadTokenRef.current; const pendingUploadToken = pendingBunnyUploadTokenRef.current;
if (!pendingVideoId || !pendingUploadToken) return; if (!pendingVideoId || !pendingUploadToken) return;
@@ -108,7 +126,9 @@ export default function NewVideoPageClient({
} }
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive); void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
}, [cleanupPendingBunnyVideo]); },
[cleanupPendingBunnyVideo]
);
useEffect(() => { useEffect(() => {
if (!isUploadingFile) return; if (!isUploadingFile) return;
@@ -206,7 +226,8 @@ export default function NewVideoPageClient({
} }
}; };
const setSelectedVideoFile = useCallback((file: File) => { const setSelectedVideoFile = useCallback(
(file: File) => {
if (!isVideoFile(file)) { if (!isVideoFile(file)) {
setSubmitError('Please select a valid video file.'); setSubmitError('Please select a valid video file.');
return; return;
@@ -219,25 +240,33 @@ export default function NewVideoPageClient({
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
setFormData((prev) => ({ ...prev, title: nameWithoutExt })); setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
} }
}, [formData.title]); },
[formData.title]
);
const handleFileDragEnter = useCallback((event: React.DragEvent<HTMLLabelElement>) => { const handleFileDragEnter = useCallback(
(event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault(); event.preventDefault();
if (isLoading) return; if (isLoading) return;
fileDragDepthRef.current += 1; fileDragDepthRef.current += 1;
if (Array.from(event.dataTransfer.types).includes('Files')) { if (Array.from(event.dataTransfer.types).includes('Files')) {
setIsFileDragOver(true); setIsFileDragOver(true);
} }
}, [isLoading]); },
[isLoading]
);
const handleFileDragOver = useCallback((event: React.DragEvent<HTMLLabelElement>) => { const handleFileDragOver = useCallback(
(event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault(); event.preventDefault();
if (isLoading) return; if (isLoading) return;
event.dataTransfer.dropEffect = 'copy'; event.dataTransfer.dropEffect = 'copy';
if (Array.from(event.dataTransfer.types).includes('Files')) { if (Array.from(event.dataTransfer.types).includes('Files')) {
setIsFileDragOver(true); setIsFileDragOver(true);
} }
}, [isLoading]); },
[isLoading]
);
const handleFileDragLeave = useCallback((event: React.DragEvent<HTMLLabelElement>) => { const handleFileDragLeave = useCallback((event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault(); event.preventDefault();
@@ -247,7 +276,8 @@ export default function NewVideoPageClient({
} }
}, []); }, []);
const handleFileDrop = useCallback((event: React.DragEvent<HTMLLabelElement>) => { const handleFileDrop = useCallback(
(event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault(); event.preventDefault();
fileDragDepthRef.current = 0; fileDragDepthRef.current = 0;
setIsFileDragOver(false); setIsFileDragOver(false);
@@ -256,17 +286,25 @@ export default function NewVideoPageClient({
const file = Array.from(event.dataTransfer.files)[0]; const file = Array.from(event.dataTransfer.files)[0];
if (!file) return; if (!file) return;
setSelectedVideoFile(file); setSelectedVideoFile(file);
}, [isLoading, setSelectedVideoFile]); },
[isLoading, setSelectedVideoFile]
);
const uploadToBunny = async ( const uploadToBunny = async (
file: File file: File
): Promise<{ videoId: string; libraryId: string; providerId: string; url: string; uploadToken: string }> => { ): Promise<{
videoId: string;
libraryId: string;
providerId: string;
url: string;
uploadToken: string;
}> => {
// 1. Initialize Bunny Stream upload (creates video & gets signature) // 1. Initialize Bunny Stream upload (creates video & gets signature)
setUploadStatus('Initializing upload...'); setUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, { const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: formData.title || file.name }) body: JSON.stringify({ title: formData.title || file.name }),
}); });
if (!initRes.ok) { if (!initRes.ok) {
@@ -274,7 +312,9 @@ export default function NewVideoPageClient({
throw new Error(data.error || 'Failed to initialize upload'); throw new Error(data.error || 'Failed to initialize upload');
} }
const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json(); const {
data: { videoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json();
setPendingBunnyVideoId(videoId); setPendingBunnyVideoId(videoId);
setPendingBunnyUploadToken(uploadToken); setPendingBunnyUploadToken(uploadToken);
pendingBunnyVideoIdRef.current = videoId; pendingBunnyVideoIdRef.current = videoId;
@@ -414,7 +454,10 @@ export default function NewVideoPageClient({
console.error('Failed to add video:', error); console.error('Failed to add video:', error);
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred'); setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) { if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
await cleanupPendingBunnyVideo(pendingBunnyVideoIdRef.current, pendingBunnyUploadTokenRef.current); await cleanupPendingBunnyVideo(
pendingBunnyVideoIdRef.current,
pendingBunnyUploadTokenRef.current
);
} }
} finally { } finally {
activeTusUploadRef.current = null; activeTusUploadRef.current = null;
@@ -455,17 +498,26 @@ export default function NewVideoPageClient({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6"> <Tabs
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}> value={uploadMode}
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger> onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')}
className="mb-6"
>
<TabsList
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
>
<TabsTrigger value="url" disabled={isLoading}>
Paste URL
</TabsTrigger>
{bunnyUploadsEnabled ? ( {bunnyUploadsEnabled ? (
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger> <TabsTrigger value="file" disabled={isLoading}>
Direct Upload
</TabsTrigger>
) : null} ) : null}
</TabsList> </TabsList>
</Tabs> </Tabs>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{uploadMode === 'url' ? ( {uploadMode === 'url' ? (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="url">Video URL</Label> <Label htmlFor="url">Video URL</Label>
@@ -492,7 +544,9 @@ export default function NewVideoPageClient({
{videoSource && ( {videoSource && (
<p className="text-sm text-green-600 flex items-center gap-1"> <p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected {videoSource.providerId.charAt(0).toUpperCase() +
videoSource.providerId.slice(1)}{' '}
video detected
{isFetchingMeta && ' — fetching metadata...'} {isFetchingMeta && ' — fetching metadata...'}
</p> </p>
)} )}
@@ -519,7 +573,9 @@ export default function NewVideoPageClient({
{selectedFile ? ( {selectedFile ? (
<> <>
<FileVideo className="w-10 h-10 mb-3 text-primary" /> <FileVideo className="w-10 h-10 mb-3 text-primary" />
<p className="mb-2 text-sm text-foreground font-medium">{selectedFile.name}</p> <p className="mb-2 text-sm text-foreground font-medium">
{selectedFile.name}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB {(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
</p> </p>
@@ -534,7 +590,14 @@ export default function NewVideoPageClient({
</> </>
)} )}
</div> </div>
<input id="file" type="file" accept="video/*" className="hidden" onChange={handleFileChange} disabled={isLoading} /> <input
id="file"
type="file"
accept="video/*"
className="hidden"
onChange={handleFileChange}
disabled={isLoading}
/>
</label> </label>
</div> </div>
</div> </div>
@@ -561,7 +624,11 @@ export default function NewVideoPageClient({
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
id="title" id="title"
placeholder={isFetchingMeta ? 'Fetching title...' : 'Video title (will auto-fill from video if empty)'} placeholder={
isFetchingMeta
? 'Fetching title...'
: 'Video title (will auto-fill from video if empty)'
}
value={formData.title} value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
disabled={isLoading} disabled={isLoading}
@@ -596,7 +663,10 @@ export default function NewVideoPageClient({
<p className="text-sm text-muted-foreground">{uploadStatus}</p> <p className="text-sm text-muted-foreground">{uploadStatus}</p>
{uploadProgress > 0 && uploadProgress < 100 && ( {uploadProgress > 0 && uploadProgress < 100 && (
<div className="w-full bg-secondary rounded-full h-2"> <div className="w-full bg-secondary rounded-full h-2">
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div> <div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
></div>
</div> </div>
)} )}
{isUploadingFile && ( {isUploadingFile && (
@@ -608,11 +678,23 @@ export default function NewVideoPageClient({
)} )}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button type="submit" disabled={isLoading || (uploadMode === 'url' && !videoSource) || (uploadMode === 'file' && !selectedFile)}> <Button
type="submit"
disabled={
isLoading ||
(uploadMode === 'url' && !videoSource) ||
(uploadMode === 'file' && !selectedFile)
}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Add Video Add Video
</Button> </Button>
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isLoading}> <Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -24,7 +24,12 @@ interface Workspace {
name: string; name: string;
} }
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [ const visibilityOptions: {
value: Visibility;
label: string;
description: string;
icon: React.ReactNode;
}[] = [
{ {
value: 'PRIVATE', value: 'PRIVATE',
label: 'Private', label: 'Private',
@@ -71,7 +76,7 @@ export default function NewProjectPage() {
setWorkspaces(workspacesData); setWorkspaces(workspacesData);
// Auto-select if only one workspace and none preselected // Auto-select if only one workspace and none preselected
if (!preselectedWorkspace && workspacesData.length === 1) { if (!preselectedWorkspace && workspacesData.length === 1) {
setFormData(prev => ({ ...prev, workspaceId: workspacesData[0].id })); setFormData((prev) => ({ ...prev, workspaceId: workspacesData[0].id }));
} }
} }
} catch { } catch {
@@ -160,7 +165,7 @@ export default function NewProjectPage() {
) : ( ) : (
<Select <Select
value={formData.workspaceId} value={formData.workspaceId}
onValueChange={(v) => setFormData(prev => ({ ...prev, workspaceId: v }))} onValueChange={(v) => setFormData((prev) => ({ ...prev, workspaceId: v }))}
> >
<SelectTrigger className="h-11"> <SelectTrigger className="h-11">
<SelectValue placeholder="Select a workspace" /> <SelectValue placeholder="Select a workspace" />
@@ -187,7 +192,7 @@ export default function NewProjectPage() {
id="name" id="name"
placeholder="e.g. Product Demo Q1" placeholder="e.g. Product Demo Q1"
value={formData.name} value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required required
disabled={isLoading} disabled={isLoading}
className="h-11" className="h-11"
@@ -203,7 +208,9 @@ export default function NewProjectPage() {
id="description" id="description"
placeholder="Brief description of what this project is about..." placeholder="Brief description of what this project is about..."
value={formData.description} value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
rows={3} rows={3}
disabled={isLoading} disabled={isLoading}
className="resize-none" className="resize-none"
@@ -217,29 +224,34 @@ export default function NewProjectPage() {
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setFormData(prev => ({ ...prev, visibility: option.value }))} onClick={() => setFormData((prev) => ({ ...prev, visibility: option.value }))}
disabled={isLoading} disabled={isLoading}
className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${formData.visibility === option.value className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${
formData.visibility === option.value
? 'border-primary bg-primary/5 ring-1 ring-primary/20' ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-border/80 hover:bg-accent/50' : 'border-border hover:border-border/80 hover:bg-accent/50'
}`} }`}
> >
<div className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${formData.visibility === option.value <div
className={`shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
formData.visibility === option.value
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
}`}> }`}
>
{option.icon} {option.icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium">{option.label}</div> <div className="font-medium">{option.label}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">{option.description}</div>
{option.description}
</div> </div>
</div> <div
<div className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${formData.visibility === option.value className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${
formData.visibility === option.value
? 'border-primary bg-primary' ? 'border-primary bg-primary'
: 'border-muted-foreground/30' : 'border-muted-foreground/30'
}`}> }`}
>
{formData.visibility === option.value && ( {formData.visibility === option.value && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" /> <div className="w-2 h-2 rounded-full bg-primary-foreground" />
)} )}
+4 -4
View File
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardHeader, CardContent } from "@/components/ui/card" import { Card, CardHeader, CardContent } from '@/components/ui/card';
function SettingsCardSkeleton({ rows }: { rows: number }) { function SettingsCardSkeleton({ rows }: { rows: number }) {
return ( return (
@@ -20,7 +20,7 @@ function SettingsCardSkeleton({ rows }: { rows: number }) {
))} ))}
</CardContent> </CardContent>
</Card> </Card>
) );
} }
export default function SettingsLoading() { export default function SettingsLoading() {
@@ -68,5 +68,5 @@ export default function SettingsLoading() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
@@ -1,7 +1,17 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard, HardDrive } from 'lucide-react'; import {
Bell,
Send,
Mail,
CheckCircle2,
AlertCircle,
Loader2,
Globe,
CreditCard,
HardDrive,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -93,16 +103,12 @@ function ToggleButton({
onClick={onToggle} onClick={onToggle}
className={cn( className={cn(
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left', 'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
enabled enabled ? 'border-primary/50 bg-primary/5' : 'border-border hover:bg-accent/50'
? 'border-primary/50 bg-primary/5'
: 'border-border hover:bg-accent/50'
)} )}
> >
<div className="flex-1 min-w-0 pr-4"> <div className="flex-1 min-w-0 pr-4">
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{label}</span>
{description && ( {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
</div> </div>
<div <div
className={cn( className={cn(
@@ -336,9 +342,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<CreditCard className="h-5 w-5" /> <CreditCard className="h-5 w-5" />
Billing Billing
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Manage your paid plan and workspace creation access</CardDescription>
Manage your paid plan and workspace creation access
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{billingLoading || !billing ? ( {billingLoading || !billing ? (
@@ -349,22 +353,25 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</div> </div>
) : !billing.isEnabled ? ( ) : !billing.isEnabled ? (
<div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground"> <div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground">
Stripe billing is disabled by this host. Workspace creation is unrestricted in this environment. Stripe billing is disabled by this host. Workspace creation is unrestricted in this
environment.
</div> </div>
) : !billing.isConfigured ? ( ) : !billing.isConfigured ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400"> <div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400">
Stripe is not configured yet. Add your Stripe environment variables before using billing. Stripe is not configured yet. Add your Stripe environment variables before using
billing.
</div> </div>
) : ( ) : (
<> <>
{!billing.subscription.hasActiveSubscription {!billing.subscription.hasActiveSubscription &&
&& !billing.subscription.hasActiveTrial !billing.subscription.hasActiveTrial &&
&& billing.subscription.isTrialEligible billing.subscription.isTrialEligible &&
&& billing.checkoutAvailable ? ( billing.checkoutAvailable ? (
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2"> <div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
<p className="text-sm font-semibold">Start your 7-day free trial</p> <p className="text-sm font-semibold">Start your 7-day free trial</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Get full access to all features no charge until the trial ends. Cancel anytime. Get full access to all features no charge until the trial ends. Cancel
anytime.
</p> </p>
</div> </div>
) : null} ) : null}
@@ -382,7 +389,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: billing.subscription.hasActiveTrial : billing.subscription.hasActiveTrial
? 'Trial access is active.' ? 'Trial access is active.'
: billing.subscription.isTrialEligible : billing.subscription.isTrialEligible
? 'You haven\'t started your free trial yet.' ? "You haven't started your free trial yet."
: 'Billing access has ended.'} : 'Billing access has ended.'}
</p> </p>
</div> </div>
@@ -393,35 +400,37 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</Badge> </Badge>
</div> </div>
{billing.subscription.hasActiveTrial {billing.subscription.hasActiveTrial &&
&& billing.subscription.trialEndsAt billing.subscription.trialEndsAt &&
&& hasScheduledCancellation ? ( hasScheduledCancellation ? (
<p <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive" Access ends on {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
>
Access ends on {' '}
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
</p> </p>
) : null} ) : null}
{billing.subscription.currentPeriodEnd ? ( {billing.subscription.currentPeriodEnd ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{hasScheduledCancellation ? 'Your subscription ends on ' : 'Current billing period ends on '} {hasScheduledCancellation
? 'Your subscription ends on '
: 'Current billing period ends on '}
{new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}. {new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.
</p> </p>
) : null} ) : null}
{hasScheduledCancellation && billing.subscription.cancelAt ? ( {hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Cancellation was scheduled on {new Date(billing.subscription.cancelAt).toLocaleDateString()}. Cancellation was scheduled on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</p> </p>
) : null} ) : null}
{!billing.subscription.hasBillingAccess {!billing.subscription.hasBillingAccess &&
&& billing.subscription.billingAccessEndedAt billing.subscription.billingAccessEndedAt &&
&& billing.subscription.storageCleanupEligibleAt ? ( billing.subscription.storageCleanupEligibleAt ? (
<p className="text-sm text-amber-700 dark:text-amber-400"> <p className="text-sm text-amber-700 dark:text-amber-400">
Stored media cleanup is scheduled after {new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()} unless billing is restored first. Stored media cleanup is scheduled after{' '}
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}{' '}
unless billing is restored first.
</p> </p>
) : null} ) : null}
@@ -493,7 +502,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<> <>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{formatBytes(storageInfo.usedBytes)} used of {formatBytes(storageInfo.limitBytes)} {formatBytes(storageInfo.usedBytes)} used of{' '}
{formatBytes(storageInfo.limitBytes)}
</span> </span>
<span <span
className={ className={
@@ -504,7 +514,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: 'text-muted-foreground' : 'text-muted-foreground'
} }
> >
{storageInfo.percentage < 0.1 ? '<0.1%' : `${storageInfo.percentage.toFixed(1)}%`} {storageInfo.percentage < 0.1
? '<0.1%'
: `${storageInfo.percentage.toFixed(1)}%`}
</span> </span>
</div> </div>
<Progress <Progress
@@ -537,40 +549,30 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
Notification Events Notification Events
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Choose which events trigger notifications</CardDescription>
Choose which events trigger notifications
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<ToggleButton <ToggleButton
enabled={settings.onNewVideo} enabled={settings.onNewVideo}
onToggle={() => onToggle={() => setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))}
setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))
}
label="New Video Added" label="New Video Added"
description="When a new video is added to one of your projects" description="When a new video is added to one of your projects"
/> />
<ToggleButton <ToggleButton
enabled={settings.onNewVersion} enabled={settings.onNewVersion}
onToggle={() => onToggle={() => setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))}
setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))
}
label="New Version Added" label="New Version Added"
description="When a new version is added to an existing video" description="When a new version is added to an existing video"
/> />
<ToggleButton <ToggleButton
enabled={settings.onNewComment} enabled={settings.onNewComment}
onToggle={() => onToggle={() => setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))}
setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))
}
label="New Comment" label="New Comment"
description="When someone leaves a comment on your videos" description="When someone leaves a comment on your videos"
/> />
<ToggleButton <ToggleButton
enabled={settings.onNewReply} enabled={settings.onNewReply}
onToggle={() => onToggle={() => setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))}
setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))
}
label="New Reply" label="New Reply"
description="When someone replies to a comment thread" description="When someone replies to a comment thread"
/> />
@@ -597,9 +599,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{settings.telegramEnabled ? 'Enabled' : 'Disabled'} {settings.telegramEnabled ? 'Enabled' : 'Disabled'}
</Badge> </Badge>
</div> </div>
<CardDescription> <CardDescription>Get instant notifications via Telegram</CardDescription>
Get instant notifications via Telegram
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground"> <div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground">
@@ -614,8 +614,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
className="text-primary underline underline-offset-2" className="text-primary underline underline-offset-2"
> >
@UserInfeBot @UserInfeBot
</a> </a>{' '}
{' '}on Telegram and send <code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat ID on Telegram and send{' '}
<code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat
ID
</li> </li>
<li> <li>
Start{' '} Start{' '}
@@ -626,8 +628,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
className="text-primary underline underline-offset-2" className="text-primary underline underline-offset-2"
> >
@openframe_bot @openframe_bot
</a> </a>{' '}
{' '}and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can message you and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can
message you
</li> </li>
<li>Paste your Chat ID below and enable notifications</li> <li>Paste your Chat ID below and enable notifications</li>
</ol> </ol>
@@ -646,9 +649,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<ToggleButton <ToggleButton
enabled={settings.telegramEnabled} enabled={settings.telegramEnabled}
onToggle={() => onToggle={() => setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))}
setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))
}
label="Enable Telegram notifications" label="Enable Telegram notifications"
/> />
@@ -687,9 +688,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<ToggleButton <ToggleButton
enabled={settings.emailEnabled} enabled={settings.emailEnabled}
onToggle={() => onToggle={() => setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))}
setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))
}
label="Enable email notifications" label="Enable email notifications"
/> />
@@ -716,16 +715,12 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<Globe className="h-5 w-5" /> <Globe className="h-5 w-5" />
Timezone Timezone
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Timestamps in notifications will use this timezone</CardDescription>
Timestamps in notifications will use this timezone
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Select <Select
value={settings.timezone} value={settings.timezone}
onValueChange={(value) => onValueChange={(value) => setSettings((s) => ({ ...s, timezone: value }))}
setSettings((s) => ({ ...s, timezone: value }))
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select timezone" /> <SelectValue placeholder="Select timezone" />
@@ -60,9 +60,8 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
const pageParam = resolvedSearchParams?.page; const pageParam = resolvedSearchParams?.page;
const parsedPage = pageParam ? Number(pageParam) : 1; const parsedPage = pageParam ? Number(pageParam) : 1;
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE const page =
? parsedPage Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE ? parsedPage : 1;
: 1;
const pageSize = 20; const pageSize = 20;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
@@ -94,7 +93,10 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
const membership = workspace.members[0]; const membership = workspace.members[0];
const isMember = !!membership; const isMember = !!membership;
const isAdmin = isOwner || membership?.role === 'ADMIN'; const isAdmin = isOwner || membership?.role === 'ADMIN';
const access = await checkWorkspaceAccess({ id: workspace.id, ownerId: workspace.ownerId }, session.user.id); const access = await checkWorkspaceAccess(
{ id: workspace.id, ownerId: workspace.ownerId },
session.user.id
);
if (!access.hasAccess || (!isOwner && !isMember)) { if (!access.hasAccess || (!isOwner && !isMember)) {
redirect('/dashboard'); redirect('/dashboard');
@@ -213,7 +215,12 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Page {page} of {totalPages} Page {page} of {totalPages}
</span> </span>
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}> <Button
variant="outline"
size="sm"
disabled={page >= totalPages}
asChild={page < totalPages}
>
{page < totalPages ? ( {page < totalPages ? (
<Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link> <Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link>
) : ( ) : (
@@ -12,7 +12,12 @@ import { Textarea } from '@/components/ui/textarea';
type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC'; type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [ const visibilityOptions: {
value: Visibility;
label: string;
description: string;
icon: React.ReactNode;
}[] = [
{ {
value: 'PRIVATE', value: 'PRIVATE',
label: 'Private', label: 'Private',
@@ -111,8 +116,7 @@ export default function NewWorkspaceProjectPageClient({ workspaceId }: { workspa
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium"> <Label htmlFor="description" className="text-sm font-medium">
Description{' '} Description <span className="text-muted-foreground font-normal">(optional)</span>
<span className="text-muted-foreground font-normal">(optional)</span>
</Label> </Label>
<Textarea <Textarea
id="description" id="description"
@@ -148,9 +148,7 @@ export default function WorkspaceSettingsPageClient({
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Workspace Settings</h1> <h1 className="text-3xl font-bold tracking-tight">Workspace Settings</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">Manage workspace configuration</p>
Manage workspace configuration
</p>
</div> </div>
<Card className="mb-8"> <Card className="mb-8">
@@ -215,9 +213,7 @@ export default function WorkspaceSettingsPageClient({
<Card className="border-destructive/50"> <Card className="border-destructive/50">
<CardHeader> <CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle> <CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription> <CardDescription>Irreversible actions. Proceed with caution.</CardDescription>
Irreversible actions. Proceed with caution.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AlertDialog> <AlertDialog>
@@ -234,11 +230,13 @@ export default function WorkspaceSettingsPageClient({
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
This will permanently delete this workspace and everything inside it This will permanently delete this workspace and everything inside it
(projects, videos, comments, images, and voice notes). This action cannot be undone. (projects, videos, comments, images, and voice notes). This action cannot
be undone.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="delete-workspace-confirm"> <Label htmlFor="delete-workspace-confirm">
Type <strong className="text-foreground">{workspace.name}</strong> to confirm Type <strong className="text-foreground">{workspace.name}</strong> to
confirm
</Label> </Label>
<Input <Input
id="delete-workspace-confirm" id="delete-workspace-confirm"
+4 -4
View File
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardHeader, CardContent } from "@/components/ui/card" import { Card, CardHeader, CardContent } from '@/components/ui/card';
function WorkspaceCardSkeleton() { function WorkspaceCardSkeleton() {
return ( return (
@@ -20,7 +20,7 @@ function WorkspaceCardSkeleton() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
export default function WorkspacesLoading() { export default function WorkspacesLoading() {
@@ -40,5 +40,5 @@ export default function WorkspacesLoading() {
))} ))}
</div> </div>
</div> </div>
) );
} }
@@ -95,9 +95,7 @@ export default function NewWorkspacePage({
id="name" id="name"
placeholder="e.g., My Studio" placeholder="e.g., My Studio"
value={formData.name} value={formData.name}
onChange={(e) => onChange={(e) => setFormData({ ...formData, name: e.target.value })}
setFormData({ ...formData, name: e.target.value })
}
required required
disabled={isLoading} disabled={isLoading}
/> />
@@ -112,9 +110,7 @@ export default function NewWorkspacePage({
id="description" id="description"
placeholder="What is this workspace for?" placeholder="What is this workspace for?"
value={formData.description} value={formData.description}
onChange={(e) => onChange={(e) => setFormData({ ...formData, description: e.target.value })}
setFormData({ ...formData, description: e.target.value })
}
rows={3} rows={3}
disabled={isLoading} disabled={isLoading}
/> />
@@ -140,7 +136,8 @@ export default function NewWorkspacePage({
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
You can still create and manage projects inside workspaces where you are already a member. You can still create and manage projects inside workspaces where you are already a
member.
</p> </p>
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link> <Link href="/settings">Open Billing Settings</Link>
+7 -4
View File
@@ -2,13 +2,16 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing'; import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { hasCollaboratorBillingBackedAccess, requireBillingAccessOrRedirect } from '@/lib/route-access'; import {
hasCollaboratorBillingBackedAccess,
requireBillingAccessOrRedirect,
} from '@/lib/route-access';
import { WorkspacesClient } from './workspaces-client'; import { WorkspacesClient } from './workspaces-client';
export default async function WorkspacesPage({ export default async function WorkspacesPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ page?: string }> searchParams: Promise<{ page?: string }>;
}) { }) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -52,7 +55,7 @@ export default async function WorkspacesPage({
{ ownerId: session.user.id, owner: buildBillingAccessWhereInput() }, { ownerId: session.user.id, owner: buildBillingAccessWhereInput() },
{ members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() }, { members: { some: { userId: session.user.id } }, owner: buildBillingAccessWhereInput() },
], ],
} },
}), }),
getBillingOverview(session.user.id), getBillingOverview(session.user.id),
]); ]);
@@ -64,7 +67,7 @@ export default async function WorkspacesPage({
name: w.name, name: w.name,
description: w.description, description: w.description,
updatedAt: w.updatedAt.toISOString(), updatedAt: w.updatedAt.toISOString(),
_count: w._count _count: w._count,
})); }));
return ( return (
@@ -55,9 +55,7 @@ export function WorkspacesClient({
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Workspaces</h1> <h1 className="text-3xl font-bold tracking-tight">Workspaces</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">Manage your workspaces and their projects</p>
Manage your workspaces and their projects
</p>
{!workspaceCreation.canCreateWorkspace && workspaceCreation.reason ? ( {!workspaceCreation.canCreateWorkspace && workspaceCreation.reason ? (
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2"> <p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
{workspaceCreation.reason} {workspaceCreation.reason}
@@ -73,9 +71,7 @@ export function WorkspacesClient({
</Button> </Button>
) : ( ) : (
<Button asChild className="w-full sm:w-auto"> <Button asChild className="w-full sm:w-auto">
<Link href="/settings"> <Link href="/settings">Upgrade to Create Workspace</Link>
Upgrade to Create Workspace
</Link>
</Button> </Button>
)} )}
</div> </div>
+14 -8
View File
@@ -20,7 +20,8 @@ export default async function AdminFeedbackDetailPage({
} }
const { feedbackId } = await params; const { feedbackId } = await params;
const userFeedbackDelegate = (db as unknown as { const userFeedbackDelegate = (
db as unknown as {
userFeedback?: { userFeedback?: {
findUnique: (args?: unknown) => Promise<{ findUnique: (args?: unknown) => Promise<{
id: string; id: string;
@@ -36,9 +37,12 @@ export default async function AdminFeedbackDetailPage({
screenshots: Array<{ id: string; url: string }>; screenshots: Array<{ id: string; url: string }>;
} | null>; } | null>;
}; };
}).userFeedback; }
).userFeedback;
let entry = null as Awaited<ReturnType<NonNullable<typeof userFeedbackDelegate>['findUnique']>> | null; let entry = null as Awaited<
ReturnType<NonNullable<typeof userFeedbackDelegate>['findUnique']>
> | null;
if (userFeedbackDelegate) { if (userFeedbackDelegate) {
try { try {
entry = await userFeedbackDelegate.findUnique({ entry = await userFeedbackDelegate.findUnique({
@@ -63,7 +67,7 @@ export default async function AdminFeedbackDetailPage({
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : ''; const message = error instanceof Error ? error.message : '';
if (message.includes('Unknown field `screenshots`')) { if (message.includes('Unknown field `screenshots`')) {
entry = await userFeedbackDelegate.findUnique({ entry = (await userFeedbackDelegate.findUnique({
where: { id: feedbackId }, where: { id: feedbackId },
include: { include: {
user: { user: {
@@ -74,7 +78,7 @@ export default async function AdminFeedbackDetailPage({
}, },
}, },
}, },
}) as typeof entry; })) as typeof entry;
if (entry && !Array.isArray(entry.screenshots)) { if (entry && !Array.isArray(entry.screenshots)) {
entry = { entry = {
@@ -95,9 +99,9 @@ export default async function AdminFeedbackDetailPage({
const screenshotItems = const screenshotItems =
entry.screenshots.length > 0 entry.screenshots.length > 0
? entry.screenshots ? entry.screenshots
: (entry.screenshotUrl : entry.screenshotUrl
? [{ id: `${entry.id}-legacy`, url: entry.screenshotUrl }] ? [{ id: `${entry.id}-legacy`, url: entry.screenshotUrl }]
: []); : [];
const submittedAtText = format(new Date(entry.createdAt), 'MMM dd, yyyy HH:mm'); const submittedAtText = format(new Date(entry.createdAt), 'MMM dd, yyyy HH:mm');
const submitterName = entry.user.name || 'there'; const submitterName = entry.user.name || 'there';
const feedbackTypeLabel = entry.type.toLowerCase(); const feedbackTypeLabel = entry.type.toLowerCase();
@@ -169,7 +173,9 @@ export default async function AdminFeedbackDetailPage({
Screenshots ({screenshotItems.length}) Screenshots ({screenshotItems.length})
</h3> </h3>
{screenshotItems.length === 0 ? ( {screenshotItems.length === 0 ? (
<div className="rounded-md border p-4 text-sm text-muted-foreground">No screenshots attached.</div> <div className="rounded-md border p-4 text-sm text-muted-foreground">
No screenshots attached.
</div>
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{screenshotItems.map((screenshot, index) => ( {screenshotItems.map((screenshot, index) => (
+110 -30
View File
@@ -18,7 +18,14 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
type SortBy = 'submittedAt' | 'type' | 'status' | 'rating' | 'user' | 'allowShowcase' | 'showOnLanding'; type SortBy =
| 'submittedAt'
| 'type'
| 'status'
| 'rating'
| 'user'
| 'allowShowcase'
| 'showOnLanding';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
type TypeFilter = 'ALL' | FeedbackEntryType; type TypeFilter = 'ALL' | FeedbackEntryType;
type StatusFilter = 'ALL' | FeedbackStatus; type StatusFilter = 'ALL' | FeedbackStatus;
@@ -39,7 +46,15 @@ type AdminFeedbackEntry = {
}; };
function parseSortBy(value: string | undefined): SortBy { function parseSortBy(value: string | undefined): SortBy {
const accepted: SortBy[] = ['submittedAt', 'type', 'status', 'rating', 'user', 'allowShowcase', 'showOnLanding']; const accepted: SortBy[] = [
'submittedAt',
'type',
'status',
'rating',
'user',
'allowShowcase',
'showOnLanding',
];
return accepted.includes(value as SortBy) ? (value as SortBy) : 'submittedAt'; return accepted.includes(value as SortBy) ? (value as SortBy) : 'submittedAt';
} }
@@ -58,7 +73,11 @@ function parseStatusFilter(value: string | undefined): StatusFilter {
return 'ALL'; return 'ALL';
} }
function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string { function getSortIndicator(
column: SortBy,
activeSortBy: SortBy,
activeSortDirection: SortDirection
): string {
if (column !== activeSortBy) return '↕'; if (column !== activeSortBy) return '↕';
return activeSortDirection === 'asc' ? '↑' : '↓'; return activeSortDirection === 'asc' ? '↑' : '↓';
} }
@@ -127,12 +146,14 @@ export default async function AdminFeedbackPage({
}; };
const orderBy = getOrderBy(sortBy, sortDirection); const orderBy = getOrderBy(sortBy, sortDirection);
const userFeedbackDelegate = (db as unknown as { const userFeedbackDelegate = (
db as unknown as {
userFeedback?: { userFeedback?: {
count: (args?: unknown) => Promise<number>; count: (args?: unknown) => Promise<number>;
findMany: (args?: unknown) => Promise<AdminFeedbackEntry[]>; findMany: (args?: unknown) => Promise<AdminFeedbackEntry[]>;
}; };
}).userFeedback; }
).userFeedback;
let totalEntries = 0; let totalEntries = 0;
let page = requestedPage; let page = requestedPage;
@@ -174,7 +195,7 @@ export default async function AdminFeedbackPage({
const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize)); const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize));
page = Math.min(requestedPage, totalPages); page = Math.min(requestedPage, totalPages);
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const fallbackEntries = await userFeedbackDelegate.findMany({ const fallbackEntries = (await userFeedbackDelegate.findMany({
where, where,
skip, skip,
take: pageSize, take: pageSize,
@@ -188,7 +209,11 @@ export default async function AdminFeedbackPage({
}, },
}, },
orderBy, orderBy,
}) as Array<Omit<AdminFeedbackEntry, 'screenshots'> & { screenshots?: Array<{ id: string; url: string }> }>; })) as Array<
Omit<AdminFeedbackEntry, 'screenshots'> & {
screenshots?: Array<{ id: string; url: string }>;
}
>;
entries = fallbackEntries.map((entry) => ({ entries = fallbackEntries.map((entry) => ({
id: entry.id, id: entry.id,
@@ -272,7 +297,12 @@ export default async function AdminFeedbackPage({
<Link href={buildFilterHref(typeFilter, 'ALL')}>All Statuses</Link> <Link href={buildFilterHref(typeFilter, 'ALL')}>All Statuses</Link>
</Button> </Button>
{(['NEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'RESOLVED'] as const).map((status) => ( {(['NEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'RESOLVED'] as const).map((status) => (
<Button key={status} variant={statusFilter === status ? 'default' : 'outline'} size="sm" asChild> <Button
key={status}
variant={statusFilter === status ? 'default' : 'outline'}
size="sm"
asChild
>
<Link href={buildFilterHref(typeFilter, status)}>{status.replace('_', ' ')}</Link> <Link href={buildFilterHref(typeFilter, status)}>{status.replace('_', ' ')}</Link>
</Button> </Button>
))} ))}
@@ -289,48 +319,83 @@ export default async function AdminFeedbackPage({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead> <TableHead>
<Link href={buildSortHref('submittedAt')} className="inline-flex items-center gap-1 hover:underline"> <Link
href={buildSortHref('submittedAt')}
className="inline-flex items-center gap-1 hover:underline"
>
Submitted Submitted
<span className="text-xs">{getSortIndicator('submittedAt', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('submittedAt', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead> <TableHead>
<Link href={buildSortHref('user')} className="inline-flex items-center gap-1 hover:underline"> <Link
href={buildSortHref('user')}
className="inline-flex items-center gap-1 hover:underline"
>
User User
<span className="text-xs">{getSortIndicator('user', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('user', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead> <TableHead>
<Link href={buildSortHref('type')} className="inline-flex items-center gap-1 hover:underline"> <Link
href={buildSortHref('type')}
className="inline-flex items-center gap-1 hover:underline"
>
Type Type
<span className="text-xs">{getSortIndicator('type', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('type', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>Message</TableHead> <TableHead>Message</TableHead>
<TableHead className="text-center">Screenshot</TableHead> <TableHead className="text-center">Screenshot</TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('rating')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('rating')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Rating Rating
<span className="text-xs">{getSortIndicator('rating', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('rating', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('status')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('status')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Status Status
<span className="text-xs">{getSortIndicator('status', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('status', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('allowShowcase')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('allowShowcase')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Consent Consent
<span className="text-xs">{getSortIndicator('allowShowcase', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('allowShowcase', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('showOnLanding')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('showOnLanding')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Landing Landing
<span className="text-xs">{getSortIndicator('showOnLanding', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('showOnLanding', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
@@ -357,7 +422,9 @@ export default async function AdminFeedbackPage({
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Badge variant="outline">{entry.type === 'FEEDBACK' ? 'Feedback' : 'Review'}</Badge> <Badge variant="outline">
{entry.type === 'FEEDBACK' ? 'Feedback' : 'Review'}
</Badge>
{entry.category && ( {entry.category && (
<Badge variant="secondary" className="w-fit"> <Badge variant="secondary" className="w-fit">
{entry.category} {entry.category}
@@ -366,12 +433,16 @@ export default async function AdminFeedbackPage({
</div> </div>
</TableCell> </TableCell>
<TableCell className="font-medium">{entry.title}</TableCell> <TableCell className="font-medium">{entry.title}</TableCell>
<TableCell className="max-w-[320px] truncate text-sm text-muted-foreground">{entry.message}</TableCell> <TableCell className="max-w-[320px] truncate text-sm text-muted-foreground">
{entry.message}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{(entry.screenshots.length > 0 || entry.screenshotUrl) ? ( {entry.screenshots.length > 0 || entry.screenshotUrl ? (
<Link href={`/admin/feedback/${entry.id}`} className="text-xs underline"> <Link href={`/admin/feedback/${entry.id}`} className="text-xs underline">
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0))} image {entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)} image
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)) > 1 ? 's' : ''} {(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)) > 1
? 's'
: ''}
</Link> </Link>
) : ( ) : (
'-' '-'
@@ -381,8 +452,12 @@ export default async function AdminFeedbackPage({
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="outline">{entry.status}</Badge> <Badge variant="outline">{entry.status}</Badge>
</TableCell> </TableCell>
<TableCell className="text-center">{entry.allowShowcase ? 'Yes' : 'No'}</TableCell> <TableCell className="text-center">
<TableCell className="text-center">{entry.showOnLanding ? 'Yes' : 'No'}</TableCell> {entry.allowShowcase ? 'Yes' : 'No'}
</TableCell>
<TableCell className="text-center">
{entry.showOnLanding ? 'Yes' : 'No'}
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
@@ -411,7 +486,12 @@ export default async function AdminFeedbackPage({
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Page {page} of {totalPages} Page {page} of {totalPages}
</span> </span>
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}> <Button
variant="outline"
size="sm"
disabled={page >= totalPages}
asChild={page < totalPages}
>
{page < totalPages ? <Link href={buildPageHref(page + 1)}>Next</Link> : 'Next'} {page < totalPages ? <Link href={buildPageHref(page + 1)}>Next</Link> : 'Next'}
</Button> </Button>
</div> </div>
+13 -8
View File
@@ -4,11 +4,7 @@ import { Header } from '@/components/layout';
import Link from 'next/link'; import Link from 'next/link';
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react'; import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
export default async function AdminLayout({ export default async function AdminLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const session = await auth(); const session = await auth();
if (!session?.user?.isAdmin) { if (!session?.user?.isAdmin) {
@@ -22,15 +18,24 @@ export default async function AdminLayout({
{/* Mobile Nav */} {/* Mobile Nav */}
<div className="md:hidden py-4 border-b mb-4"> <div className="md:hidden py-4 border-b mb-4">
<nav className="flex items-center gap-4 overflow-x-auto"> <nav className="flex items-center gap-4 overflow-x-auto">
<Link href="/admin" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"> <Link
href="/admin"
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
>
<LayoutDashboard className="h-4 w-4" /> <LayoutDashboard className="h-4 w-4" />
Dashboard Dashboard
</Link> </Link>
<Link href="/admin/users" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"> <Link
href="/admin/users"
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
>
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Users Users
</Link> </Link>
<Link href="/admin/feedback" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"> <Link
href="/admin/feedback"
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
>
<MessageSquareQuote className="h-4 w-4" /> <MessageSquareQuote className="h-4 w-4" />
Feedback Feedback
</Link> </Link>
+33 -9
View File
@@ -3,10 +3,30 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getCachedBunnyStorageStats, getCachedTotalStorage, getCachedStripeStats } from '@/lib/admin-stats'; import {
getCachedBunnyStorageStats,
getCachedTotalStorage,
getCachedStripeStats,
} from '@/lib/admin-stats';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button'; import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star, CreditCard, TrendingUp, UserCheck, AlertCircle, UserX } from 'lucide-react'; import {
Users,
Folder,
Video,
MessageSquare,
Mic,
HardDrive,
Image as ImageIcon,
Film,
MessageSquareQuote,
Star,
CreditCard,
TrendingUp,
UserCheck,
AlertCircle,
UserX,
} from 'lucide-react';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Admin Dashboard | OpenFrame', title: 'Admin Dashboard | OpenFrame',
@@ -39,9 +59,11 @@ export default async function AdminDashboardPage() {
redirect('/'); redirect('/');
} }
const userFeedbackDelegate = (db as unknown as { const userFeedbackDelegate = (
db as unknown as {
userFeedback?: { count: (args?: unknown) => Promise<number> }; userFeedback?: { count: (args?: unknown) => Promise<number> };
}).userFeedback; }
).userFeedback;
// 1. Database Stats // 1. Database Stats
const [ const [
@@ -111,9 +133,7 @@ export default async function AdminDashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{totalProjects}</div> <div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">Total active projects on the platform</p>
Total active projects on the platform
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -186,7 +206,9 @@ export default async function AdminDashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'} {isBunnyUploadsFeatureEnabled()
? formatBytes(bunnyStorageStats.totalBytes)
: 'Disabled'}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -202,7 +224,9 @@ export default async function AdminDashboardPage() {
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatMrr(stripeStats.mrrCents, stripeStats.currency)}</div> <div className="text-2xl font-bold">
{formatMrr(stripeStats.mrrCents, stripeStats.currency)}
</div>
<p className="text-xs text-muted-foreground">Based on active subscriptions</p> <p className="text-xs text-muted-foreground">Based on active subscriptions</p>
</CardContent> </CardContent>
</Card> </Card>
+114 -76
View File
@@ -8,7 +8,7 @@ import {
getCachedBunnyStorageStats, getCachedBunnyStorageStats,
getCachedUserBunnyStorage, getCachedUserBunnyStorage,
getCachedUserDownloadEgress, getCachedUserDownloadEgress,
getCachedUserMediaStorage getCachedUserMediaStorage,
} from '@/lib/admin-stats'; } from '@/lib/admin-stats';
import { Film, HardDrive } from 'lucide-react'; import { Film, HardDrive } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
@@ -71,28 +71,33 @@ function getDefaultSortDirection(sortBy: SortBy): SortDirection {
return sortBy === 'user' ? 'asc' : 'desc'; return sortBy === 'user' ? 'asc' : 'desc';
} }
function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string { function getSortIndicator(
column: SortBy,
activeSortBy: SortBy,
activeSortDirection: SortDirection
): string {
if (column !== activeSortBy) return '↕'; if (column !== activeSortBy) return '↕';
return activeSortDirection === 'asc' ? '↑' : '↓'; return activeSortDirection === 'asc' ? '↑' : '↓';
} }
function canSortInDb(sortBy: SortBy): boolean { function canSortInDb(sortBy: SortBy): boolean {
return sortBy === 'user' return (
|| sortBy === 'joinedDate' sortBy === 'user' ||
|| sortBy === 'workspacesOwned' sortBy === 'joinedDate' ||
|| sortBy === 'projectsOwned' sortBy === 'workspacesOwned' ||
|| sortBy === 'totalComments'; sortBy === 'projectsOwned' ||
sortBy === 'totalComments'
);
} }
function getUsersOrderBy(sortBy: SortBy, sortDirection: SortDirection): Prisma.UserOrderByWithRelationInput[] { function getUsersOrderBy(
sortBy: SortBy,
sortDirection: SortDirection
): Prisma.UserOrderByWithRelationInput[] {
const createdAtTieBreaker: Prisma.UserOrderByWithRelationInput = { createdAt: 'desc' }; const createdAtTieBreaker: Prisma.UserOrderByWithRelationInput = { createdAt: 'desc' };
if (sortBy === 'user') { if (sortBy === 'user') {
return [ return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker];
{ name: sortDirection },
{ email: sortDirection },
createdAtTieBreaker,
];
} }
if (sortBy === 'joinedDate') { if (sortBy === 'joinedDate') {
@@ -100,33 +105,24 @@ function getUsersOrderBy(sortBy: SortBy, sortDirection: SortDirection): Prisma.U
} }
if (sortBy === 'workspacesOwned') { if (sortBy === 'workspacesOwned') {
return [ return [{ ownedWorkspaces: { _count: sortDirection } }, createdAtTieBreaker];
{ ownedWorkspaces: { _count: sortDirection } },
createdAtTieBreaker,
];
} }
if (sortBy === 'projectsOwned') { if (sortBy === 'projectsOwned') {
return [ return [{ projects: { _count: sortDirection } }, createdAtTieBreaker];
{ projects: { _count: sortDirection } },
createdAtTieBreaker,
];
} }
if (sortBy === 'totalComments') { if (sortBy === 'totalComments') {
return [ return [{ comments: { _count: sortDirection } }, createdAtTieBreaker];
{ comments: { _count: sortDirection } },
createdAtTieBreaker,
];
} }
return [createdAtTieBreaker]; return [createdAtTieBreaker];
} }
export default async function AdminUsersPage({ export default async function AdminUsersPage({
searchParams searchParams,
}: { }: {
searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }> searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }>;
}) { }) {
const session = await auth(); const session = await auth();
if (!session?.user?.isAdmin) { if (!session?.user?.isAdmin) {
@@ -135,13 +131,16 @@ export default async function AdminUsersPage({
const resolvedSearchParams = await searchParams; const resolvedSearchParams = await searchParams;
const requestedPage = Number(resolvedSearchParams?.page) || 1; const requestedPage = Number(resolvedSearchParams?.page) || 1;
const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy) ? resolvedSearchParams.sortBy : 'joinedDate'; const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy)
? resolvedSearchParams.sortBy
: 'joinedDate';
const sortDirection: SortDirection = const sortDirection: SortDirection =
resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc' resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc'
? resolvedSearchParams.sortDirection ? resolvedSearchParams.sortDirection
: getDefaultSortDirection(sortBy); : getDefaultSortDirection(sortBy);
const pageSize = 20; const pageSize = 20;
const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] = await Promise.all([ const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] =
await Promise.all([
db.user.count(), db.user.count(),
getCachedUserMediaStorage(), getCachedUserMediaStorage(),
getCachedUserBunnyStorage(), getCachedUserBunnyStorage(),
@@ -162,17 +161,17 @@ export default async function AdminUsersPage({
_count: { _count: {
select: { select: {
members: true, members: true,
} },
} },
} },
}, },
_count: { _count: {
select: { select: {
ownedWorkspaces: true, ownedWorkspaces: true,
projects: true, projects: true,
comments: true, comments: true,
} },
} },
} satisfies Prisma.UserSelect; } satisfies Prisma.UserSelect;
let paginatedUsers: Array<{ let paginatedUsers: Array<{
@@ -282,7 +281,9 @@ export default async function AdminUsersPage({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'} {isBunnyUploadsFeatureEnabled()
? formatBytes(bunnyStorageStats.totalBytes)
: 'Disabled'}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -312,57 +313,102 @@ export default async function AdminUsersPage({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead> <TableHead>
<Link href={buildSortHref('user')} className="inline-flex items-center gap-1 hover:underline"> <Link
href={buildSortHref('user')}
className="inline-flex items-center gap-1 hover:underline"
>
User User
<span className="text-xs">{getSortIndicator('user', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('user', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead> <TableHead>
<Link href={buildSortHref('joinedDate')} className="inline-flex items-center gap-1 hover:underline"> <Link
href={buildSortHref('joinedDate')}
className="inline-flex items-center gap-1 hover:underline"
>
Joined Date Joined Date
<span className="text-xs">{getSortIndicator('joinedDate', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('joinedDate', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('workspacesOwned')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('workspacesOwned')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Workspaces Owned Workspaces Owned
<span className="text-xs">{getSortIndicator('workspacesOwned', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('workspacesOwned', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('invitedMembers')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('invitedMembers')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Invited Members Invited Members
<span className="text-xs">{getSortIndicator('invitedMembers', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('invitedMembers', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('projectsOwned')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('projectsOwned')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Projects Owned Projects Owned
<span className="text-xs">{getSortIndicator('projectsOwned', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('projectsOwned', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
<Link href={buildSortHref('totalComments')} className="inline-flex items-center justify-center gap-1 hover:underline"> <Link
href={buildSortHref('totalComments')}
className="inline-flex items-center justify-center gap-1 hover:underline"
>
Total Comments Total Comments
<span className="text-xs">{getSortIndicator('totalComments', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('totalComments', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<Link href={buildSortHref('bunnyUpload')} className="inline-flex items-center justify-end gap-1 hover:underline"> <Link
href={buildSortHref('bunnyUpload')}
className="inline-flex items-center justify-end gap-1 hover:underline"
>
Bunny Upload Bunny Upload
<span className="text-xs">{getSortIndicator('bunnyUpload', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('bunnyUpload', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<Link href={buildSortHref('downloadEgress')} className="inline-flex items-center justify-end gap-1 hover:underline"> <Link
href={buildSortHref('downloadEgress')}
className="inline-flex items-center justify-end gap-1 hover:underline"
>
Download Egress (Est.) Download Egress (Est.)
<span className="text-xs">{getSortIndicator('downloadEgress', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('downloadEgress', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<Link href={buildSortHref('mediaStorage')} className="inline-flex items-center justify-end gap-1 hover:underline"> <Link
href={buildSortHref('mediaStorage')}
className="inline-flex items-center justify-end gap-1 hover:underline"
>
Media Storage Media Storage
<span className="text-xs">{getSortIndicator('mediaStorage', sortBy, sortDirection)}</span> <span className="text-xs">
{getSortIndicator('mediaStorage', sortBy, sortDirection)}
</span>
</Link> </Link>
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -383,9 +429,7 @@ export default async function AdminUsersPage({
<span className="text-xs text-muted-foreground">{user.email}</span> <span className="text-xs text-muted-foreground">{user.email}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>{format(new Date(user.createdAt), 'MMM dd, yyyy')}</TableCell>
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
</TableCell>
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell> <TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
<TableCell className="text-center">{user.invitedMembersCount}</TableCell> <TableCell className="text-center">{user.invitedMembersCount}</TableCell>
<TableCell className="text-center">{user._count.projects}</TableCell> <TableCell className="text-center">{user._count.projects}</TableCell>
@@ -398,12 +442,19 @@ export default async function AdminUsersPage({
</TableCell> </TableCell>
<TableCell className="text-right text-sm"> <TableCell className="text-right text-sm">
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="font-medium text-foreground">{formatBytes(user.mediaStorageBytes)}</span> <span className="font-medium text-foreground">
{formatBytes(user.mediaStorageBytes)}
</span>
{(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && ( {(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
<span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1"> <span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1">
{userStorage[user.id]?.voice > 0 && <span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>} {userStorage[user.id]?.voice > 0 && (
{userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && <span></span>} <span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>
{userStorage[user.id]?.image > 0 && <span>🖼 {formatBytes(userStorage[user.id]?.image)}</span>} )}
{userStorage[user.id]?.voice > 0 &&
userStorage[user.id]?.image > 0 && <span></span>}
{userStorage[user.id]?.image > 0 && (
<span>🖼 {formatBytes(userStorage[user.id]?.image)}</span>
)}
</span> </span>
)} )}
</div> </div>
@@ -417,17 +468,8 @@ export default async function AdminUsersPage({
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-end space-x-2 py-4"> <div className="flex items-center justify-end space-x-2 py-4">
<Button <Button variant="outline" size="sm" disabled={page <= 1} asChild={page > 1}>
variant="outline" {page > 1 ? <Link href={buildUsersPageHref(page - 1)}>Previous</Link> : 'Previous'}
size="sm"
disabled={page <= 1}
asChild={page > 1}
>
{page > 1 ? (
<Link href={buildUsersPageHref(page - 1)}>Previous</Link>
) : (
"Previous"
)}
</Button> </Button>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Page {page} of {totalPages} Page {page} of {totalPages}
@@ -438,11 +480,7 @@ export default async function AdminUsersPage({
disabled={page >= totalPages} disabled={page >= totalPages}
asChild={page < totalPages} asChild={page < totalPages}
> >
{page < totalPages ? ( {page < totalPages ? <Link href={buildUsersPageHref(page + 1)}>Next</Link> : 'Next'}
<Link href={buildUsersPageHref(page + 1)}>Next</Link>
) : (
"Next"
)}
</Button> </Button>
</div> </div>
)} )}
+30 -12
View File
@@ -26,7 +26,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
} }
const { feedbackId } = await params; const { feedbackId } = await params;
const userFeedbackDelegate = (db as unknown as { const userFeedbackDelegate = (
db as unknown as {
userFeedback?: { userFeedback?: {
findUnique: (args: unknown) => Promise<{ findUnique: (args: unknown) => Promise<{
id: string; id: string;
@@ -34,30 +35,44 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
screenshots?: Array<{ url: string }>; screenshots?: Array<{ url: string }>;
} | null>; } | null>;
delete: (args: { where: { id: string } }) => Promise<{ id: string }>; delete: (args: { where: { id: string } }) => Promise<{ id: string }>;
findFirst: (args: { where: { screenshotUrl: string }; select: { id: true } }) => Promise<{ id: string } | null>; findFirst: (args: {
where: { screenshotUrl: string };
select: { id: true };
}) => Promise<{ id: string } | null>;
}; };
userFeedbackScreenshot?: { userFeedbackScreenshot?: {
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>; findFirst: (args: {
where: { url: string };
select: { id: true };
}) => Promise<{ id: string } | null>;
}; };
}).userFeedback; }
const userFeedbackScreenshotDelegate = (db as unknown as { ).userFeedback;
const userFeedbackScreenshotDelegate = (
db as unknown as {
userFeedbackScreenshot?: { userFeedbackScreenshot?: {
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>; findFirst: (args: {
where: { url: string };
select: { id: true };
}) => Promise<{ id: string } | null>;
}; };
}).userFeedbackScreenshot; }
).userFeedbackScreenshot;
if (!userFeedbackDelegate) { if (!userFeedbackDelegate) {
return apiErrors.internalError('Feedback model is not available yet'); return apiErrors.internalError('Feedback model is not available yet');
} }
let feedbackRecord = await userFeedbackDelegate.findUnique({ let feedbackRecord = await userFeedbackDelegate
.findUnique({
where: { id: feedbackId }, where: { id: feedbackId },
include: { include: {
screenshots: { screenshots: {
select: { url: true }, select: { url: true },
}, },
}, },
}).catch((error) => { })
.catch((error) => {
const message = error instanceof Error ? error.message : ''; const message = error instanceof Error ? error.message : '';
if (message.includes('Unknown field `screenshots`')) return null; if (message.includes('Unknown field `screenshots`')) return null;
throw error; throw error;
@@ -88,7 +103,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const filename = extractImageFilenameFromProxyUrl(url); const filename = extractImageFilenameFromProxyUrl(url);
if (!filename) return; if (!filename) return;
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = await Promise.all([ const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] =
await Promise.all([
db.comment.findFirst({ db.comment.findFirst({
where: { imageUrl: url }, where: { imageUrl: url },
select: { id: true }, select: { id: true },
@@ -107,12 +123,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
if (commentReferenced || feedbackReferenced || feedbackAttachmentReferenced) return; if (commentReferenced || feedbackReferenced || feedbackAttachmentReferenced) return;
await r2Client.send( await r2Client
.send(
new DeleteObjectCommand({ new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME, Bucket: R2_BUCKET_NAME,
Key: `images/${filename}`, Key: `images/${filename}`,
}) })
).catch(() => undefined); )
.catch(() => undefined);
}) })
); );
+14 -5
View File
@@ -29,7 +29,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: { include: {
video: { video: {
include: { include: {
project: { select: { id: true, ownerId: true, workspaceId: true, visibility: true } }, project: {
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
},
}, },
}, },
}, },
@@ -38,7 +40,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}); });
if (!approvalRequest) return apiErrors.notFound('Approval request'); if (!approvalRequest) return apiErrors.notFound('Approval request');
const access = await checkProjectAccess(approvalRequest.version.video.project, session.user.id, { intent: 'manage' }); const access = await checkProjectAccess(
approvalRequest.version.video.project,
session.user.id,
{ intent: 'manage' }
);
const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit; const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit;
if (!canCancel) return apiErrors.forbidden('Access denied'); if (!canCancel) return apiErrors.forbidden('Access denied');
@@ -46,7 +52,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.conflict('Only pending approval requests can be canceled'); return apiErrors.conflict('Only pending approval requests can be canceled');
} }
const updated = await db.$transaction(async (tx) => { const updated = await db.$transaction(
async (tx) => {
const current = await tx.approvalRequest.findUnique({ const current = await tx.approvalRequest.findUnique({
where: { id: requestId }, where: { id: requestId },
select: { status: true }, select: { status: true },
@@ -70,9 +77,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}, },
}, },
}); });
}, { },
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}); }
);
const response = successResponse({ request: updated }); const response = successResponse({ request: updated });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
@@ -41,7 +41,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: { include: {
video: { video: {
include: { include: {
project: { select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true } }, project: {
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
},
},
}, },
}, },
}, },
@@ -66,7 +74,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.conflict('You have already responded to this request'); return apiErrors.conflict('You have already responded to this request');
} }
const updated = await db.$transaction(async (tx) => { const updated = await db.$transaction(
async (tx) => {
const currentRequest = await tx.approvalRequest.findUnique({ const currentRequest = await tx.approvalRequest.findUnique({
where: { id: requestId }, where: { id: requestId },
include: { include: {
@@ -159,9 +168,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}, },
}, },
}); });
}, { },
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}); }
);
if (!updated) return apiErrors.notFound('Approval request'); if (!updated) return apiErrors.notFound('Approval request');
@@ -212,9 +223,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.message === '__NOT_PENDING__') return apiErrors.conflict('This approval request is no longer pending'); if (error.message === '__NOT_PENDING__')
if (error.message === '__ALREADY_RESPONDED__') return apiErrors.conflict('You have already responded to this request'); return apiErrors.conflict('This approval request is no longer pending');
if (error.message === '__NOT_APPROVER__') return apiErrors.forbidden('You are not an approver on this request'); if (error.message === '__ALREADY_RESPONDED__')
return apiErrors.conflict('You have already responded to this request');
if (error.message === '__NOT_APPROVER__')
return apiErrors.forbidden('You are not an approver on this request');
if (error.message === '__NOT_FOUND__') return apiErrors.notFound('Approval request'); if (error.message === '__NOT_FOUND__') return apiErrors.notFound('Approval request');
} }
if (isSerializableConflict(error)) { if (isSerializableConflict(error)) {
+15 -7
View File
@@ -2,11 +2,20 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations'; import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations';
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit'; import {
checkRateLimit,
getClientIp,
rateLimitHeaders,
RATE_LIMIT_CONFIGS,
} from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { isInviteCodeRequired } from '@/lib/feature-flags'; import { isInviteCodeRequired } from '@/lib/feature-flags';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { createVerificationToken, isEmailVerificationEnabled, sendVerificationEmail } from '@/lib/email-verification'; import {
createVerificationToken,
isEmailVerificationEnabled,
sendVerificationEmail,
} from '@/lib/email-verification';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -117,7 +126,9 @@ export async function POST(request: NextRequest) {
}); });
if (result !== 'accepted') { if (result !== 'accepted') {
await db.user.delete({ where: { id: user.id } }); await db.user.delete({ where: { id: user.id } });
return apiErrors.conflict('Invitation could not be accepted. Please request a new invitation.'); return apiErrors.conflict(
'Invitation could not be accepted. Please request a new invitation.'
);
} }
} }
@@ -131,10 +142,7 @@ export async function POST(request: NextRequest) {
? 'Account created. Please check your email to verify your address before signing in.' ? 'Account created. Please check your email to verify your address before signing in.'
: 'Account created successfully'; : 'Account created successfully';
const response = successResponse( const response = successResponse({ message, user, emailVerificationRequired }, 201);
{ message, user, emailVerificationRequired },
201
);
// Add rate limit headers to successful response // Add rate limit headers to successful response
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests); const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
+12 -3
View File
@@ -2,7 +2,11 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { createVerificationToken, isEmailVerificationEnabled, sendVerificationEmail } from '@/lib/email-verification'; import {
createVerificationToken,
isEmailVerificationEnabled,
sendVerificationEmail,
} from '@/lib/email-verification';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -13,7 +17,10 @@ export async function POST(request: NextRequest) {
// Rate-limit by IP to prevent abuse // Rate-limit by IP to prevent abuse
const clientIp = getClientIp(request); const clientIp = getClientIp(request);
const rateLimitResult = await checkRateLimit(`resend-verification:${clientIp}`, 'resend-verification'); const rateLimitResult = await checkRateLimit(
`resend-verification:${clientIp}`,
'resend-verification'
);
if (!rateLimitResult.allowed) { if (!rateLimitResult.allowed) {
return apiErrors.rateLimited('Too many requests. Please try again later.'); return apiErrors.rateLimited('Too many requests. Please try again later.');
} }
@@ -45,7 +52,9 @@ export async function POST(request: NextRequest) {
} }
return withCacheControl( return withCacheControl(
successResponse({ message: 'If that email has an unverified account, a new verification link has been sent.' }), successResponse({
message: 'If that email has an unverified account, a new verification link has been sent.',
}),
'private, no-store' 'private, no-store'
); );
} catch (err) { } catch (err) {
+2 -1
View File
@@ -34,7 +34,8 @@ export async function GET() {
cancelAt: billing.subscription.cancelAt?.toISOString() ?? null, cancelAt: billing.subscription.cancelAt?.toISOString() ?? null,
trialEndsAt: billing.subscription.trialEndsAt?.toISOString() ?? null, trialEndsAt: billing.subscription.trialEndsAt?.toISOString() ?? null,
billingAccessEndedAt: billing.subscription.billingAccessEndedAt?.toISOString() ?? null, billingAccessEndedAt: billing.subscription.billingAccessEndedAt?.toISOString() ?? null,
storageCleanupEligibleAt: billing.subscription.storageCleanupEligibleAt?.toISOString() ?? null, storageCleanupEligibleAt:
billing.subscription.storageCleanupEligibleAt?.toISOString() ?? null,
}, },
workspaceCreation: billing.workspaceCreation, workspaceCreation: billing.workspaceCreation,
}); });
+43 -18
View File
@@ -137,10 +137,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const isOwner = userId === project.ownerId; const isOwner = userId === project.ownerId;
const isAuthor = !!userId && comment.authorId === userId; const isAuthor = !!userId && comment.authorId === userId;
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null; const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
const isGuestAuthor = !userId const isGuestAuthor =
&& !comment.authorId !userId &&
&& !!comment.guestIdentityId !comment.authorId &&
&& guestIdentityId === comment.guestIdentityId; !!comment.guestIdentityId &&
guestIdentityId === comment.guestIdentityId;
const canEditOwnContent = isAuthor || isGuestAuthor; const canEditOwnContent = isAuthor || isGuestAuthor;
const canResolveComment = access.canEdit; const canResolveComment = access.canEdit;
@@ -154,15 +155,25 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests); hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
const hasGuestAccess =
project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
if (!hasGuestAccess) { if (!hasGuestAccess) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
} }
// Only author can edit content or tag // Only author can edit content or tag
if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !canEditOwnContent) { if (
(content !== undefined || tagId !== undefined || annotationData !== undefined) &&
!canEditOwnContent
) {
return apiErrors.forbidden('Only the author can edit comment content'); return apiErrors.forbidden('Only the author can edit comment content');
} }
@@ -229,10 +240,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
replies: updatedComment.replies.map((reply) => { replies: updatedComment.replies.map((reply) => {
const canEditReply = !!userId const canEditReply = !!userId
? reply.authorId === userId ? reply.authorId === userId
: !!guestIdentityId : !!guestIdentityId &&
&& !reply.authorId !reply.authorId &&
&& !!reply.guestIdentityId !!reply.guestIdentityId &&
&& reply.guestIdentityId === guestIdentityId; reply.guestIdentityId === guestIdentityId;
const replyData = Object.fromEntries( const replyData = Object.fromEntries(
Object.entries(reply).filter(([key]) => key !== 'guestIdentityId') Object.entries(reply).filter(([key]) => key !== 'guestIdentityId')
); );
@@ -290,9 +301,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
let canDelete = isAuthor || isPrivilegedUser; let canDelete = isAuthor || isPrivilegedUser;
if (!canDelete && !userId) { if (!canDelete && !userId) {
const guestIdentityId = getGuestIdentityFromRequest(request); const guestIdentityId = getGuestIdentityFromRequest(request);
const isGuestAuthor = !comment.authorId const isGuestAuthor =
&& !!comment.guestIdentityId !comment.authorId &&
&& guestIdentityId === comment.guestIdentityId; !!comment.guestIdentityId &&
guestIdentityId === comment.guestIdentityId;
if (isGuestAuthor) { if (isGuestAuthor) {
const shareSession = getShareSessionFromRequest(request, comment.version.video.id); const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
@@ -304,8 +316,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests); hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
const hasGuestAccess =
project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
if (!hasGuestAccess) { if (!hasGuestAccess) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -331,7 +350,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
// Clean up media files from R2 (best-effort, don't block on failure) // Clean up media files from R2 (best-effort, don't block on failure)
const AUDIO_PREFIX = '/api/upload/audio/'; const AUDIO_PREFIX = '/api/upload/audio/';
const IMAGE_PREFIX = '/api/upload/image/'; const IMAGE_PREFIX = '/api/upload/image/';
const mediaKeys = [...new Set(mediaUrls.map((url) => { const mediaKeys = [
...new Set(
mediaUrls
.map((url) => {
// Extract filename using string parsing (safe against ReDoS) // Extract filename using string parsing (safe against ReDoS)
if (url.includes(AUDIO_PREFIX)) { if (url.includes(AUDIO_PREFIX)) {
const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length); const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
@@ -342,7 +364,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return filename ? `images/${filename}` : null; return filename ? `images/${filename}` : null;
} }
return null; return null;
}).filter((key): key is string => Boolean(key)))]; })
.filter((key): key is string => Boolean(key))
),
];
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => { await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
try { try {
+25 -10
View File
@@ -37,7 +37,9 @@ export async function POST(request: NextRequest) {
? body.screenshotUrls ? body.screenshotUrls
.map((url) => (typeof url === 'string' ? url.trim() : '')) .map((url) => (typeof url === 'string' ? url.trim() : ''))
.filter((url) => !!url) .filter((url) => !!url)
: (legacyScreenshotUrl ? [legacyScreenshotUrl] : []); : legacyScreenshotUrl
? [legacyScreenshotUrl]
: [];
if (type !== FeedbackEntryType.FEEDBACK && type !== FeedbackEntryType.REVIEW) { if (type !== FeedbackEntryType.FEEDBACK && type !== FeedbackEntryType.REVIEW) {
return apiErrors.badRequest('Invalid entry type'); return apiErrors.badRequest('Invalid entry type');
@@ -70,7 +72,11 @@ export async function POST(request: NextRequest) {
} }
if (type === FeedbackEntryType.REVIEW) { if (type === FeedbackEntryType.REVIEW) {
if (!Number.isInteger(body.rating) || (body.rating as number) < 1 || (body.rating as number) > 5) { if (
!Number.isInteger(body.rating) ||
(body.rating as number) < 1 ||
(body.rating as number) > 5
) {
return apiErrors.badRequest('Review rating must be between 1 and 5'); return apiErrors.badRequest('Review rating must be between 1 and 5');
} }
} }
@@ -83,13 +89,15 @@ export async function POST(request: NextRequest) {
data: { data: {
userId: session.user.id, userId: session.user.id,
type, type,
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null, category:
type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
title, title,
message, message,
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null, screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
rating: type === FeedbackEntryType.REVIEW ? body.rating : null, rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false, allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
screenshots: type === FeedbackEntryType.FEEDBACK screenshots:
type === FeedbackEntryType.FEEDBACK
? { ? {
create: screenshotUrls.map((url) => ({ url })), create: screenshotUrls.map((url) => ({ url })),
} }
@@ -112,7 +120,8 @@ export async function POST(request: NextRequest) {
data: { data: {
userId: session.user.id, userId: session.user.id,
type, type,
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null, category:
type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
title, title,
message, message,
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null, screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
@@ -128,19 +137,25 @@ export async function POST(request: NextRequest) {
} }
if (usedLegacyCreatePath && type === FeedbackEntryType.FEEDBACK && screenshotUrls.length > 1) { if (usedLegacyCreatePath && type === FeedbackEntryType.FEEDBACK && screenshotUrls.length > 1) {
const screenshotDelegate = (db as unknown as { const screenshotDelegate = (
db as unknown as {
userFeedbackScreenshot?: { userFeedbackScreenshot?: {
createMany: (args: { data: Array<{ feedbackId: string; url: string }> }) => Promise<unknown>; createMany: (args: {
data: Array<{ feedbackId: string; url: string }>;
}) => Promise<unknown>;
}; };
}).userFeedbackScreenshot; }
).userFeedbackScreenshot;
if (screenshotDelegate) { if (screenshotDelegate) {
await screenshotDelegate.createMany({ await screenshotDelegate
.createMany({
data: screenshotUrls.map((url) => ({ data: screenshotUrls.map((url) => ({
feedbackId: entry.id, feedbackId: entry.id,
url, url,
})), })),
}).catch(() => undefined); })
.catch(() => undefined);
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
// POST /api/feedback/upload // POST /api/feedback/upload
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
+4 -4
View File
@@ -12,10 +12,10 @@ export async function POST() {
const cfg = RATE_LIMIT_CONFIGS['onboarding-complete']; const cfg = RATE_LIMIT_CONFIGS['onboarding-complete'];
const rl = await checkRateLimit(session.user.id, 'onboarding-complete', cfg); const rl = await checkRateLimit(session.user.id, 'onboarding-complete', cfg);
if (!rl.allowed) { if (!rl.allowed) {
return new Response( return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
JSON.stringify({ error: 'Too many requests. Please try again later.' }), status: 429,
{ status: 429, headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) } } headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) },
); });
} }
await db.user.update({ await db.user.update({
@@ -3,7 +3,11 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { InvitationRole, ProjectMemberRole } from '@prisma/client'; import { InvitationRole, ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations'; import {
buildInvitationUrl,
createOrRefreshInvitation,
sendInvitationEmail,
} from '@/lib/invitations';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
+7 -1
View File
@@ -44,7 +44,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests; hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
} }
} }
@@ -30,7 +30,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
project: true, project: true,
versions: { versions: {
orderBy: { versionNumber: 'desc' }, orderBy: { versionNumber: 'desc' },
...(includeComments ? { ...(includeComments
? {
include: { include: {
comments: { comments: {
orderBy: { timestamp: 'asc' }, orderBy: { timestamp: 'asc' },
@@ -57,7 +58,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// guestEmail excluded for privacy // guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } }, tag: { select: { id: true, name: true, color: true } },
...(includeReplies ? { ...(includeReplies
? {
replies: { replies: {
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
select: { select: {
@@ -83,13 +85,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
tag: { select: { id: true, name: true, color: true } }, tag: { select: { id: true, name: true, color: true } },
}, },
}, },
} : {}), }
: {}),
}, },
where: { parentId: null }, where: { parentId: null },
}, },
_count: { select: { comments: true } }, _count: { select: { comments: true } },
}, },
} : { }
: {
select: { select: {
id: true, id: true,
thumbnailUrl: true, thumbnailUrl: true,
@@ -149,7 +149,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false; const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
const password = typeof body?.password === 'string' ? body.password.trim() : ''; const password = typeof body?.password === 'string' ? body.password.trim() : '';
if (password.length > MAX_SHARE_PASSWORD_LENGTH) { if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`); return apiErrors.badRequest(
`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`
);
} }
const passwordHash = password ? await bcrypt.hash(password, 12) : null; const passwordHash = password ? await bcrypt.hash(password, 12) : null;
const token = randomBytes(24).toString('base64url'); const token = randomBytes(24).toString('base64url');
@@ -166,7 +168,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} | null = null; } | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
try { try {
link = await db.$transaction(async (tx) => { link = await db.$transaction(
async (tx) => {
const existing = await tx.shareLink.findFirst({ const existing = await tx.shareLink.findFirst({
where: { where: {
projectId, projectId,
@@ -221,10 +224,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
passwordHash: true, passwordHash: true,
}, },
}); });
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
break; break;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034' && attempt < 2) { if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2034' &&
attempt < 2
) {
continue; continue;
} }
throw error; throw error;
@@ -261,11 +270,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined; const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined; const allowDownloads =
typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
const rawPassword = typeof body?.password === 'string' ? body.password : undefined; const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
const clearPassword = body?.clearPassword === true; const clearPassword = body?.clearPassword === true;
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) { if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`); return apiErrors.badRequest(
`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`
);
} }
const existing = await db.shareLink.findFirst({ const existing = await db.shareLink.findFirst({
@@ -9,7 +9,12 @@ import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> }; type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
async function getVersionWithAccess(projectId: string, videoId: string, versionId: string, userId: string) { async function getVersionWithAccess(
projectId: string,
videoId: string,
versionId: string,
userId: string
) {
const version = await db.videoVersion.findFirst({ const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId }, where: { id: versionId, videoParentId: videoId },
include: { include: {
@@ -55,7 +60,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const body = await request.json(); const body = await request.json();
const { duration, versionLabel, isActive } = body; const { duration, versionLabel, isActive } = body;
if (duration !== undefined && (typeof duration !== 'number' || !isFinite(duration) || duration < 0)) { if (
duration !== undefined &&
(typeof duration !== 'number' || !isFinite(duration) || duration < 0)
) {
return apiErrors.badRequest('Invalid duration value'); return apiErrors.badRequest('Invalid duration value');
} }
@@ -87,7 +87,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
thumbnailUrl, thumbnailUrl,
duration, duration,
setActive, setActive,
uploadToken uploadToken,
} = body; } = body;
if (!videoUrl) { if (!videoUrl) {
@@ -114,10 +114,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest(thumbnailUrlError); return apiErrors.badRequest(thumbnailUrlError);
} }
const normalizedProviderId = typeof providerId === 'string' && providerId.trim() const normalizedProviderId =
typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase() ? providerId.trim().toLowerCase()
: 'youtube'; : 'youtube';
const normalizedProviderVideoId = typeof providerVideoId === 'string' ? providerVideoId.trim() : ''; const normalizedProviderVideoId =
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : ''; const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') { if (normalizedProviderId === 'bunny') {
@@ -138,7 +140,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1; const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
// Use transaction to handle active flag // Use transaction to handle active flag
const version = await db.$transaction(async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => { const version = await db.$transaction(
async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => {
// If setActive, deactivate all other versions // If setActive, deactivate all other versions
if (setActive) { if (setActive) {
await tx.videoVersion.updateMany({ await tx.videoVersion.updateMany({
@@ -164,7 +167,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
_count: { select: { comments: true } }, _count: { select: { comments: true } },
}, },
}); });
}); }
);
// Notify project owner (fire-and-forget, skip if they added it themselves) // Notify project owner (fire-and-forget, skip if they added it themselves)
if (video.project.ownerId !== session.user.id) { if (video.project.ownerId !== session.user.id) {
@@ -15,7 +15,14 @@ type RouteParams = { params: Promise<{ projectId: string }> };
async function getProjectWithEditAccess(projectId: string, userId: string) { async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({ const project = await db.project.findUnique({
where: { id: projectId }, where: { id: projectId },
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true, workspace: { select: { ownerId: true } } }, select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
workspace: { select: { ownerId: true } },
},
}); });
if (!project) return null; if (!project) return null;
@@ -61,7 +68,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (quotaError) return quotaError; if (quotaError) return quotaError;
const apiKey = process.env.BUNNY_STREAM_API_KEY; const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID; const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) { if (!apiKey || !libraryId) {
return apiErrors.internalError('Bunny Stream is not configured correctly'); return apiErrors.internalError('Bunny Stream is not configured correctly');
@@ -71,11 +79,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, { const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
method: 'POST', method: 'POST',
headers: { headers: {
'AccessKey': apiKey, AccessKey: apiKey,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json' Accept: 'application/json',
}, },
body: JSON.stringify({ title }) body: JSON.stringify({ title }),
}); });
if (!bunnyRes.ok) { if (!bunnyRes.ok) {
@@ -96,11 +104,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + videoId); hash.update(libraryId + apiKey + expirationTime + videoId);
const signature = hash.digest('hex'); const signature = hash.digest('hex');
const uploadToken = createBunnyUploadToken({ const uploadToken = createBunnyUploadToken(
{
userId: session.user.id, userId: session.user.id,
projectId, projectId,
videoId, videoId,
}, 3600); },
3600
);
const response = successResponse({ const response = successResponse({
videoId, videoId,
+12 -2
View File
@@ -88,7 +88,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); const body = await request.json();
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration, uploadToken } = body; const {
title,
description,
videoUrl,
providerId,
videoId,
thumbnailUrl,
duration,
uploadToken,
} = body;
if (!title || !videoUrl) { if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required'); return apiErrors.badRequest('Title and video URL are required');
@@ -105,7 +114,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest(thumbnailUrlError); return apiErrors.badRequest(thumbnailUrlError);
} }
const normalizedProviderId = typeof providerId === 'string' && providerId.trim() const normalizedProviderId =
typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase() ? providerId.trim().toLowerCase()
: 'youtube'; : 'youtube';
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : ''; const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
+12 -10
View File
@@ -48,12 +48,16 @@ export async function GET(request: NextRequest) {
{ ownerId: session.user.id }, { ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } }, { members: { some: { userId: session.user.id } } },
// Also include projects in workspaces where the user is a workspace member // Also include projects in workspaces where the user is a workspace member
...(workspaceId ? [] : [{ ...(workspaceId
? []
: [
{
workspace: { workspace: {
owner: buildBillingAccessWhereInput(), owner: buildBillingAccessWhereInput(),
members: { some: { userId: session.user.id } }, members: { some: { userId: session.user.id } },
}, },
}]), },
]),
], ],
workspace: { workspace: {
owner: buildBillingAccessWhereInput(), owner: buildBillingAccessWhereInput(),
@@ -82,16 +86,12 @@ export async function GET(request: NextRequest) {
}), }),
]); ]);
const response = successResponse( const response = successResponse({ projects }, 200, {
{ projects },
200,
{
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
} });
);
return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60');
} catch (error) { } catch (error) {
@@ -120,7 +120,9 @@ export async function POST(request: NextRequest) {
} }
if (!workspaceId || typeof workspaceId !== 'string') { if (!workspaceId || typeof workspaceId !== 'string') {
return apiErrors.badRequest('A workspace is required. Every project must belong to a workspace.'); return apiErrors.badRequest(
'A workspace is required. Every project must belong to a workspace.'
);
} }
// Generate URL-friendly slug // Generate URL-friendly slug
@@ -138,7 +140,7 @@ export async function POST(request: NextRequest) {
}); });
// Generate unique slug from the results // Generate unique slug from the results
const usedSlugs = new Set(existingProjects.map(p => p.slug)); const usedSlugs = new Set(existingProjects.map((p) => p.slug));
let slug = baseSlug; let slug = baseSlug;
let counter = 1; let counter = 1;
while (usedSlugs.has(slug)) { while (usedSlugs.has(slug)) {
+5 -8
View File
@@ -21,10 +21,10 @@ export async function GET(request: NextRequest) {
const cfg = RATE_LIMIT_CONFIGS['search']; const cfg = RATE_LIMIT_CONFIGS['search'];
const rl = await checkRateLimit(userId, 'search', cfg); const rl = await checkRateLimit(userId, 'search', cfg);
if (!rl.allowed) { if (!rl.allowed) {
return new Response( return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
JSON.stringify({ error: 'Too many requests. Please try again later.' }), status: 429,
{ status: 429, headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) } } headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) },
); });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -48,10 +48,7 @@ export async function GET(request: NextRequest) {
}; };
const workspaceAccessFilter = { const workspaceAccessFilter = {
OR: [ OR: [{ ownerId: userId }, { members: { some: { userId } } }],
{ ownerId: userId },
{ members: { some: { userId } } },
],
}; };
const [projects, workspaces, videos] = await Promise.all([ const [projects, workspaces, videos] = await Promise.all([
+7 -2
View File
@@ -155,7 +155,9 @@ export async function POST(request: NextRequest) {
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
logError('Telegram test failed:', (data as { description?: string }).description); logError('Telegram test failed:', (data as { description?: string }).description);
return apiErrors.badRequest('Telegram test failed: check that the Chat ID is correct and the bot has been started'); return apiErrors.badRequest(
'Telegram test failed: check that the Chat ID is correct and the bot has been started'
);
} }
const response = successResponse({ message: 'Test message sent to Telegram' }); const response = successResponse({ message: 'Test message sent to Telegram' });
@@ -188,7 +190,10 @@ export async function POST(request: NextRequest) {
auth: { user: smtpUser, pass: smtpPass }, auth: { user: smtpUser, pass: smtpPass },
}); });
const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>'; const fromAddress =
process.env.SMTP_FROM ||
process.env.EMAIL_FROM ||
'OpenFrame <[email protected]>';
try { try {
await transporter.sendMail({ await transporter.sendMail({
+2 -7
View File
@@ -1,9 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import { import { markSubscriptionCanceledByCustomerId, syncStripeSubscriptionToUser } from '@/lib/billing';
markSubscriptionCanceledByCustomerId,
syncStripeSubscriptionToUser,
} from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe'; import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
@@ -11,9 +8,7 @@ export const runtime = 'nodejs';
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = const customerId =
typeof subscription.customer === 'string' typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
? subscription.customer
: subscription.customer.id;
const currentPeriodEnd = const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number' 'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
+29 -6
View File
@@ -17,10 +17,17 @@ import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-qu
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
// Canonical MIME types accepted // Canonical MIME types accepted
const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']); const ALLOWED_TYPES = new Set([
'audio/webm',
'audio/ogg',
'audio/opus',
'audio/mp4',
'audio/mpeg',
'audio/wav',
]);
// Normalize known MIME aliases to canonical values // Normalize known MIME aliases to canonical values
const MIME_ALIASES: Record<string, string> = { const MIME_ALIASES: Record<string, string> = {
@@ -49,7 +56,11 @@ const SAFE_AUDIO_EXTENSIONS = new Set(['webm', 'ogg', 'opus', 'mp3', 'm4a', 'mp4
// Reject content that looks like HTML/XML/script regardless of the declared MIME type. // Reject content that looks like HTML/XML/script regardless of the declared MIME type.
function isHtmlContent(bytes: Buffer): boolean { function isHtmlContent(bytes: Buffer): boolean {
const snippet = bytes.toString('latin1', 0, Math.min(bytes.length, 512)).trimStart().slice(0, 50).toLowerCase(); const snippet = bytes
.toString('latin1', 0, Math.min(bytes.length, 512))
.trimStart()
.slice(0, 50)
.toLowerCase();
return ( return (
snippet.startsWith('<!doctype') || snippet.startsWith('<!doctype') ||
snippet.startsWith('<html') || snippet.startsWith('<html') ||
@@ -139,9 +150,16 @@ export async function POST(request: NextRequest) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
const canCommentWithMembership = !!session?.user?.id && access.hasAccess; const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); const canCommentWithShareLink =
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
if (!canCommentWithMembership && !canCommentWithShareLink) { if (!canCommentWithMembership && !canCommentWithShareLink) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -166,7 +184,12 @@ export async function POST(request: NextRequest) {
return apiErrors.forbidden('Invalid upload token'); return apiErrors.forbidden('Invalid upload token');
} }
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'audio', shareSession?.token ?? null); const quotaError = await enforceGuestUploadQuota(
request,
safeVideoId,
'audio',
shareSession?.token ?? null
);
if (quotaError) return quotaError; if (quotaError) return quotaError;
} }
+16 -4
View File
@@ -23,7 +23,7 @@ import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota'; import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -85,9 +85,16 @@ export async function POST(request: NextRequest) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
const canCommentWithMembership = !!session?.user?.id && access.hasAccess; const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); const canCommentWithShareLink =
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
if (!canCommentWithMembership && !canCommentWithShareLink) { if (!canCommentWithMembership && !canCommentWithShareLink) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
@@ -112,7 +119,12 @@ export async function POST(request: NextRequest) {
return apiErrors.forbidden('Invalid upload token'); return apiErrors.forbidden('Invalid upload token');
} }
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'image', shareSession?.token ?? null); const quotaError = await enforceGuestUploadQuota(
request,
safeVideoId,
'image',
shareSession?.token ?? null
);
if (quotaError) return quotaError; if (quotaError) return quotaError;
} }
@@ -75,28 +75,40 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
include: { include: {
video: { video: {
include: { include: {
project: { select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true } }, project: {
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true },
},
}, },
}, },
}, },
}); });
if (!version) return apiErrors.notFound('Version'); if (!version) return apiErrors.notFound('Version');
const access = await checkProjectAccess(version.video.project, session.user.id, { intent: 'manage' }); const access = await checkProjectAccess(version.video.project, session.user.id, {
intent: 'manage',
});
if (!access.canEdit) return apiErrors.forbidden('Access denied'); if (!access.canEdit) return apiErrors.forbidden('Access denied');
const body = await request.json().catch(() => ({})) as { approverIds?: unknown; message?: unknown }; const body = (await request.json().catch(() => ({}))) as {
approverIds?: unknown;
message?: unknown;
};
const message = typeof body.message === 'string' ? body.message.trim() : ''; const message = typeof body.message === 'string' ? body.message.trim() : '';
if (message.length > 2000) { if (message.length > 2000) {
return apiErrors.badRequest('Message must be 2000 characters or fewer'); return apiErrors.badRequest('Message must be 2000 characters or fewer');
} }
const rawApproverIds = Array.isArray(body.approverIds) ? body.approverIds : []; const rawApproverIds = Array.isArray(body.approverIds) ? body.approverIds : [];
const approverIds = Array.from(new Set( const approverIds = Array.from(
new Set(
rawApproverIds rawApproverIds
.filter((approverId): approverId is string => typeof approverId === 'string' && approverId.trim().length > 0) .filter(
(approverId): approverId is string =>
typeof approverId === 'string' && approverId.trim().length > 0
)
.map((approverId) => approverId.trim()) .map((approverId) => approverId.trim())
)); )
);
if (approverIds.length === 0) { if (approverIds.length === 0) {
return apiErrors.badRequest('At least one approver is required'); return apiErrors.badRequest('At least one approver is required');
@@ -114,7 +126,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('One or more approvers are not eligible for this project'); return apiErrors.badRequest('One or more approvers are not eligible for this project');
} }
const created = await db.$transaction(async (tx) => { const created = await db.$transaction(
async (tx) => {
const existingPending = await tx.approvalRequest.findFirst({ const existingPending = await tx.approvalRequest.findFirst({
where: { versionId, status: 'PENDING' }, where: { versionId, status: 'PENDING' },
select: { id: true }, select: { id: true },
@@ -148,9 +161,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}, },
}, },
}); });
}, { },
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}); }
);
const requesterName = session.user.name || 'A team member'; const requesterName = session.user.name || 'A team member';
const versionLabel = version.versionLabel || `Version ${version.versionNumber}`; const versionLabel = version.versionLabel || `Version ${version.versionNumber}`;
+57 -22
View File
@@ -8,15 +8,25 @@ import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session'; import { getShareSessionFromRequest } from '@/lib/share-session';
import { HeadObjectCommand } from '@aws-sdk/client-s3'; import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; import {
import { extractImageFileNameFromProxyUrl, extractAudioFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets'; ensureGuestIdentityFromRequest,
getGuestIdentityFromRequest,
setGuestIdentityCookie,
} from '@/lib/guest-identity';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
sanitizeAssetDisplayName,
} from '@/lib/video-assets';
import { validateAnnotationStrokes } from '@/lib/validation'; import { validateAnnotationStrokes } from '@/lib/validation';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota'; import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
type RouteParams = { params: Promise<{ versionId: string }> }; type RouteParams = { params: Promise<{ versionId: string }> };
const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; const SAFE_IMAGE_PATH =
const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const SAFE_AUDIO_PATH =
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint }; type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
@@ -111,10 +121,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const etag = `"comments:${versionId}:${includeResolved ? 1 : 0}:${commentsRevision._count.id}:${commentsRevision._max.updatedAt?.getTime() ?? 0}"`; const etag = `"comments:${versionId}:${includeResolved ? 1 : 0}:${commentsRevision._count.id}:${commentsRevision._max.updatedAt?.getTime() ?? 0}"`;
const ifNoneMatch = request.headers.get('if-none-match'); const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch) { if (ifNoneMatch) {
const matches = ifNoneMatch const matches = ifNoneMatch.split(',').map(normalizeEtag).includes(normalizeEtag(etag));
.split(',')
.map(normalizeEtag)
.includes(normalizeEtag(etag));
if (matches) { if (matches) {
const notModified = new NextResponse(null, { status: 304 }); const notModified = new NextResponse(null, { status: 304 });
@@ -234,7 +241,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
// Check if user can comment // Check if user can comment
const canComment = access.hasAccess || shareAccess.canComment; const canComment = access.hasAccess || shareAccess.canComment;
@@ -243,7 +256,19 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); const body = await request.json();
const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl, annotationData } = body; const {
content,
timestamp,
timestampEnd,
parentId,
voiceUrl,
voiceDuration,
guestName,
guestEmail,
tagId,
imageUrl,
annotationData,
} = body;
// Validate required fields // Validate required fields
if (timestamp === undefined || timestamp === null) { if (timestamp === undefined || timestamp === null) {
@@ -256,7 +281,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
if (!content && !voiceUrl && !imageUrl && !annotationData) { if (!content && !voiceUrl && !imageUrl && !annotationData) {
return apiErrors.badRequest('Either content, a voice recording, an image attachment, or an annotation is required'); return apiErrors.badRequest(
'Either content, a voice recording, an image attachment, or an annotation is required'
);
} }
// Length limits to prevent DB bloat and DoS on export/notification paths // Length limits to prevent DB bloat and DoS on export/notification paths
@@ -354,7 +381,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// see each other's in-flight sizes, eliminating the TOCTOU race. // see each other's in-flight sizes, eliminating the TOCTOU race.
const totalAttachmentBytes = voiceSizeBytes + imageSizeBytes; const totalAttachmentBytes = voiceSizeBytes + imageSizeBytes;
if (totalAttachmentBytes > BigInt(0)) { if (totalAttachmentBytes > BigInt(0)) {
const reserveResult = await reserveStorageQuota(project.workspace.ownerId, totalAttachmentBytes); const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
totalAttachmentBytes
);
if ('error' in reserveResult) return reserveResult.error; if ('error' in reserveResult) return reserveResult.error;
attachmentReservationId = reserveResult.reservationId; attachmentReservationId = reserveResult.reservationId;
} }
@@ -363,7 +393,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Consume the reservation inside the transaction so quota is never double-counted. // Consume the reservation inside the transaction so quota is never double-counted.
const result = await db.$transaction(async (tx) => { const result = await db.$transaction(async (tx) => {
if (attachmentReservationId) { if (attachmentReservationId) {
await tx.uploadReservation.deleteMany({ where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId } }); await tx.uploadReservation.deleteMany({
where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId },
});
} }
const comment = await tx.comment.create({ const comment = await tx.comment.create({
data: { data: {
@@ -378,7 +410,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
authorId: session?.user?.id || null, authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null, guestEmail: isGuest ? guestEmail : null,
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, guestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
tagId: tagId || null, tagId: tagId || null,
versionId, versionId,
}, },
@@ -410,7 +442,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
thumbnailUrl: imageUrl, thumbnailUrl: imageUrl,
sizeBytes: imageSizeBytes, sizeBytes: imageSizeBytes,
uploadedByUserId: session?.user?.id || null, uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
uploadedByGuestName: isGuest ? safeGuestName : null, uploadedByGuestName: isGuest ? safeGuestName : null,
billedUserId: project.workspace.ownerId, billedUserId: project.workspace.ownerId,
}, },
@@ -432,7 +464,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
sourceUrl: voiceUrl, sourceUrl: voiceUrl,
sizeBytes: voiceSizeBytes, sizeBytes: voiceSizeBytes,
uploadedByUserId: session?.user?.id || null, uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
uploadedByGuestName: isGuest ? safeGuestName : null, uploadedByGuestName: isGuest ? safeGuestName : null,
billedUserId: project.workspace.ownerId, billedUserId: project.workspace.ownerId,
}, },
@@ -486,21 +518,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const viewerUserId = session?.user?.id ?? null; const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId const viewerGuestIdentityId = viewerUserId
? null ? null
: guestIdentity?.identityId ?? getGuestIdentityFromRequest(request); : (guestIdentity?.identityId ?? getGuestIdentityFromRequest(request));
const canEditComment = viewerUserId const canEditComment = viewerUserId
? comment.authorId === viewerUserId ? comment.authorId === viewerUserId
: !!viewerGuestIdentityId : !!viewerGuestIdentityId &&
&& !!comment.guestIdentityId !!comment.guestIdentityId &&
&& comment.guestIdentityId === viewerGuestIdentityId; comment.guestIdentityId === viewerGuestIdentityId;
const commentData = Object.fromEntries( const commentData = Object.fromEntries(
Object.entries(comment).filter(([key]) => key !== 'guestIdentityId') Object.entries(comment).filter(([key]) => key !== 'guestIdentityId')
); );
const response = successResponse({ const response = successResponse(
{
...commentData, ...commentData,
canEdit: canEditComment, canEdit: canEditComment,
canDelete: canEditComment || viewerUserId === project.ownerId, canDelete: canEditComment || viewerUserId === project.ownerId,
}, 201); },
201
);
if (isGuest && guestIdentity?.shouldSetCookie) { if (isGuest && guestIdentity?.shouldSetCookie) {
setGuestIdentityCookie(response, guestIdentity.identityId); setGuestIdentityCookie(response, guestIdentity.identityId);
} }
+35 -8
View File
@@ -48,7 +48,10 @@ function buildBunnySourceCacheKey(
return `${videoId}:${requestedQuality ?? 'none'}:${sourcePreference}`; return `${videoId}:${requestedQuality ?? 'none'}:${sourcePreference}`;
} }
function getCachedBunnyDownloadSource(cacheKey: string, now: number): BunnyDownloadSource | null | undefined { function getCachedBunnyDownloadSource(
cacheKey: string,
now: number
): BunnyDownloadSource | null | undefined {
const cached = bunnyDownloadSourceCache.get(cacheKey); const cached = bunnyDownloadSourceCache.get(cacheKey);
if (!cached) return undefined; if (!cached) return undefined;
@@ -60,7 +63,11 @@ function getCachedBunnyDownloadSource(cacheKey: string, now: number): BunnyDownl
return cached.source; return cached.source;
} }
function setCachedBunnyDownloadSource(cacheKey: string, source: BunnyDownloadSource | null, now: number): void { function setCachedBunnyDownloadSource(
cacheKey: string,
source: BunnyDownloadSource | null,
now: number
): void {
if (bunnyDownloadSourceCache.size >= BUNNY_SOURCE_CACHE_MAX_ENTRIES) { if (bunnyDownloadSourceCache.size >= BUNNY_SOURCE_CACHE_MAX_ENTRIES) {
// Evict the oldest entry (Maps preserve insertion order) // Evict the oldest entry (Maps preserve insertion order)
const firstKey = bunnyDownloadSourceCache.keys().next().value; const firstKey = bunnyDownloadSourceCache.keys().next().value;
@@ -146,7 +153,10 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
return null; return null;
} }
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> { async function resolveBunnyCompressedSource(
videoId: string,
requestedQuality: number | null
): Promise<BunnyDownloadSource> {
const hostname = resolveBunnyCdnHostname(); const hostname = resolveBunnyCdnHostname();
if (!hostname) { if (!hostname) {
return { return {
@@ -156,7 +166,11 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
}; };
} }
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) { if (
typeof requestedQuality === 'number' &&
Number.isFinite(requestedQuality) &&
requestedQuality > 0
) {
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`; const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
if (await isRemoteFileAvailable(requestedUrl)) { if (await isRemoteFileAvailable(requestedUrl)) {
return { return {
@@ -243,7 +257,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const rawQuality = searchParams.get('quality'); const rawQuality = searchParams.get('quality');
const sourceParam = searchParams.get('source'); const sourceParam = searchParams.get('source');
const sourcePreference: BunnyDownloadSourcePreference = const sourcePreference: BunnyDownloadSourcePreference =
sourceParam === null ? 'auto' : sourceParam === 'original' || sourceParam === 'compressed' sourceParam === null
? 'auto'
: sourceParam === 'original' || sourceParam === 'compressed'
? sourceParam ? sourceParam
: 'auto'; : 'auto';
@@ -281,7 +297,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'VIEW', requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload; const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
if (!access.hasAccess && !canDownloadViaShareLink) { if (!access.hasAccess && !canDownloadViaShareLink) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
@@ -299,7 +321,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
rawQuality !== null && rawQuality !== null &&
(!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality)) (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
) { ) {
return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'); return apiErrors.badRequest(
'Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'
);
} }
if (rawQuality !== null && sourcePreference === 'original') { if (rawQuality !== null && sourcePreference === 'original') {
@@ -349,7 +373,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
workspaceId: version.video.project.workspace.id, workspaceId: version.video.project.workspace.id,
billedUserId: version.video.project.workspace.ownerId, billedUserId: version.video.project.workspace.ownerId,
downloaderUserId: session?.user?.id ?? null, downloaderUserId: session?.user?.id ?? null,
source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED, source:
source.sourceType === 'original'
? DownloadEgressSource.ORIGINAL
: DownloadEgressSource.COMPRESSED,
quality: source.quality, quality: source.quality,
estimatedBytes, estimatedBytes,
}, },
@@ -152,10 +152,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed'); return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
} }
if ( if (
rawQuality !== null rawQuality !== null &&
&& (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality)) (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
) { ) {
return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'); return apiErrors.badRequest(
'Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'
);
} }
if (rawQuality !== null && sourcePreference === 'original') { if (rawQuality !== null && sourcePreference === 'original') {
return apiErrors.badRequest('Quality cannot be used when source=original'); return apiErrors.badRequest('Quality cannot be used when source=original');
@@ -6,10 +6,7 @@ import { db } from '@/lib/db';
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup'; import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup'; import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings'; import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
import { import { canDeleteAssetForViewer, getVideoAssetAccessContext } from '@/lib/video-assets';
canDeleteAssetForViewer,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> }; type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
@@ -72,12 +69,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]); r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
} }
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined; let bunnyCleanupResult:
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>
| undefined;
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) { if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([{ bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([
{
providerId: 'bunny', providerId: 'bunny',
videoId: asset.providerVideoId, videoId: asset.providerVideoId,
}]); },
]);
} }
const cleanupInput = { const cleanupInput = {
@@ -43,12 +43,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const shareSession = getShareSessionFromRequest(request, context.video.id); const shareSession = getShareSessionFromRequest(request, context.video.id);
if (!context.viewerUserId) { if (!context.viewerUserId) {
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null); const quotaError = await enforceGuestUploadQuota(
request,
context.video.id,
'bunny',
shareSession?.token ?? null
);
if (quotaError) return quotaError; if (quotaError) return quotaError;
} }
const apiKey = process.env.BUNNY_STREAM_API_KEY; const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID; const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) { if (!apiKey || !libraryId) {
return apiErrors.internalError('Bunny Stream is not configured correctly'); return apiErrors.internalError('Bunny Stream is not configured correctly');
} }
@@ -81,23 +87,29 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
let uploadToken = ''; let uploadToken = '';
if (context.viewerUserId) { if (context.viewerUserId) {
uploadToken = createBunnyUploadToken({ uploadToken = createBunnyUploadToken(
{
userId: context.viewerUserId, userId: context.viewerUserId,
projectId: context.video.projectId, projectId: context.video.projectId,
videoId: bunnyVideoId, videoId: bunnyVideoId,
}, 3600); },
3600
);
} else { } else {
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) { if (!expectedContext) {
return apiErrors.forbidden('Missing trusted client IP header'); return apiErrors.forbidden('Missing trusted client IP header');
} }
uploadToken = createGuestUploadToken({ uploadToken = createGuestUploadToken(
{
projectId: context.video.projectId, projectId: context.video.projectId,
videoId: context.video.id, videoId: context.video.id,
intent: 'bunny', intent: 'bunny',
context: expectedContext, context: expectedContext,
}, 3600); },
3600
);
} }
const response = successResponse({ const response = successResponse({
+48 -27
View File
@@ -25,7 +25,12 @@ import {
sanitizeAssetDisplayName, sanitizeAssetDisplayName,
} from '@/lib/video-assets'; } from '@/lib/video-assets';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { enforceStorageQuota, reserveStorageQuota, releaseStorageReservation, PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota'; import {
enforceStorageQuota,
reserveStorageQuota,
releaseStorageReservation,
PLAN_STORAGE_LIMIT_BYTES,
} from '@/lib/storage-quota';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { isStripeFeatureEnabled } from '@/lib/feature-flags';
@@ -69,10 +74,7 @@ type YouTubeTitleCacheRecord = {
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>(); const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
function isAllowedBunnyMediaUrl(url: string): boolean { function isAllowedBunnyMediaUrl(url: string): boolean {
const allowedHosts = new Set<string>([ const allowedHosts = new Set<string>(['iframe.mediadelivery.net', 'video.bunnycdn.com']);
'iframe.mediadelivery.net',
'video.bunnycdn.com',
]);
const bunnyCdnHostname = resolveServerBunnyCdnHostname(); const bunnyCdnHostname = resolveServerBunnyCdnHostname();
if (bunnyCdnHostname) { if (bunnyCdnHostname) {
allowedHosts.add(bunnyCdnHostname); allowedHosts.add(bunnyCdnHostname);
@@ -87,7 +89,11 @@ function isAllowedBunnyMediaUrl(url: string): boolean {
} }
} }
function shapeAssetForViewer(asset: AssetWithViewerFields, canExposeSource: boolean, canDelete: boolean) { function shapeAssetForViewer(
asset: AssetWithViewerFields,
canExposeSource: boolean,
canDelete: boolean
) {
return { return {
id: asset.id, id: asset.id,
videoId: asset.videoId, videoId: asset.videoId,
@@ -161,10 +167,12 @@ async function isFreshImageAttachment(url: string): Promise<AttachmentCheck> {
if (!key) return { isFresh: false, sizeBytes: BigInt(0) }; if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
try { try {
const head = await r2Client.send(new HeadObjectCommand({ const head = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME, Bucket: R2_BUCKET_NAME,
Key: key, Key: key,
})); })
);
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) }; if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) }; return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
@@ -178,10 +186,12 @@ async function isFreshAudioAttachment(url: string): Promise<AttachmentCheck> {
if (!key) return { isFresh: false, sizeBytes: BigInt(0) }; if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
try { try {
const head = await r2Client.send(new HeadObjectCommand({ const head = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME, Bucket: R2_BUCKET_NAME,
Key: key, Key: key,
})); })
);
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) }; if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) }; return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
@@ -201,7 +211,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
if (!context) return apiErrors.notFound('Video'); if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied'); if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
const requestedLimit = parsePaginationParam(request.nextUrl.searchParams.get('limit'), ASSET_LIST_DEFAULT_LIMIT); const requestedLimit = parsePaginationParam(
request.nextUrl.searchParams.get('limit'),
ASSET_LIST_DEFAULT_LIMIT
);
const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0); const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0);
const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit)); const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit));
const offset = requestedOffset; const offset = requestedOffset;
@@ -215,10 +228,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const etag = `"assets:${videoId}:${limit}:${offset}:${includeDeleteMetadata ? 1 : 0}:${context.canDownloadAssets ? 1 : 0}:${assetsRevision._count.id}:${assetsRevision._max.updatedAt?.getTime() ?? 0}"`; const etag = `"assets:${videoId}:${limit}:${offset}:${includeDeleteMetadata ? 1 : 0}:${context.canDownloadAssets ? 1 : 0}:${assetsRevision._count.id}:${assetsRevision._max.updatedAt?.getTime() ?? 0}"`;
const ifNoneMatch = request.headers.get('if-none-match'); const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch) { if (ifNoneMatch) {
const matches = ifNoneMatch const matches = ifNoneMatch.split(',').map(normalizeEtag).includes(normalizeEtag(etag));
.split(',')
.map(normalizeEtag)
.includes(normalizeEtag(etag));
if (matches) { if (matches) {
const notModified = new NextResponse(null, { status: 304 }); const notModified = new NextResponse(null, { status: 304 });
notModified.headers.set('ETag', etag); notModified.headers.set('ETag', etag);
@@ -254,12 +264,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const pagedAssets = hasMore ? assets.slice(0, limit) : assets; const pagedAssets = hasMore ? assets.slice(0, limit) : assets;
const response = successResponse({ const response = successResponse({
assets: pagedAssets.map((asset) => shapeAssetForViewer( assets: pagedAssets.map((asset) =>
shapeAssetForViewer(
asset, asset,
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio // R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
context.canDownloadAssets || (asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess), context.canDownloadAssets ||
(asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
)), )
),
pagination: { pagination: {
limit, limit,
offset, offset,
@@ -378,7 +391,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
providerVideoId = parsedSource.videoId; providerVideoId = parsedSource.videoId;
const youtubeTitle = await fetchYouTubeTitle(providerVideoId); const youtubeTitle = await fetchYouTubeTitle(providerVideoId);
displayName = sanitizeAssetDisplayName(requestedDisplayName, youtubeTitle || `YouTube ${providerVideoId}`); displayName = sanitizeAssetDisplayName(
requestedDisplayName,
youtubeTitle || `YouTube ${providerVideoId}`
);
sourceUrl = parsedSource.originalUrl; sourceUrl = parsedSource.originalUrl;
thumbnailUrl = getThumbnailUrl(parsedSource, 'large'); thumbnailUrl = getThumbnailUrl(parsedSource, 'large');
kind = 'VIDEO'; kind = 'VIDEO';
@@ -386,7 +402,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (provider === VideoAssetProvider.BUNNY) { if (provider === VideoAssetProvider.BUNNY) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
providerVideoId = typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : ''; providerVideoId =
typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : ''; const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null; thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
@@ -515,10 +532,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
thumbnailUrl, thumbnailUrl,
sizeBytes: assetSizeBytes, sizeBytes: assetSizeBytes,
uploadedByUserId: context.viewerUserId, uploadedByUserId: context.viewerUserId,
uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null, uploadedByGuestIdentityId: context.viewerUserId
? null
: (guestIdentity?.identityId ?? null),
uploadedByGuestName: context.viewerUserId uploadedByGuestName: context.viewerUserId
? null ? null
: sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'), : sanitizeAssetDisplayName(
typeof body?.guestName === 'string' ? body.guestName : null,
'Guest'
),
billedUserId, billedUserId,
}, },
select: { select: {
@@ -540,11 +562,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}); });
}); });
const response = successResponse(shapeAssetForViewer( const response = successResponse(
created, shapeAssetForViewer(created, context.canDownloadAssets, true),
context.canDownloadAssets, 201
true );
), 201);
if (isGuest && guestIdentity?.shouldSetCookie) { if (isGuest && guestIdentity?.shouldSetCookie) {
setGuestIdentityCookie(response, guestIdentity.identityId); setGuestIdentityCookie(response, guestIdentity.identityId);
} }
+13 -2
View File
@@ -87,11 +87,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const MAX_VIDEO_SECONDS = 86_400; // 24 hours — reasonable upper bound for any video const MAX_VIDEO_SECONDS = 86_400; // 24 hours — reasonable upper bound for any video
if (typeof progress !== 'number' || !isFinite(progress) || progress < 0 || progress > MAX_VIDEO_SECONDS) { if (
typeof progress !== 'number' ||
!isFinite(progress) ||
progress < 0 ||
progress > MAX_VIDEO_SECONDS
) {
return apiErrors.badRequest('Invalid progress value'); return apiErrors.badRequest('Invalid progress value');
} }
if (duration !== undefined && (typeof duration !== 'number' || !isFinite(duration) || duration < 0 || duration > MAX_VIDEO_SECONDS)) { if (
duration !== undefined &&
(typeof duration !== 'number' ||
!isFinite(duration) ||
duration < 0 ||
duration > MAX_VIDEO_SECONDS)
) {
return apiErrors.badRequest('Invalid duration value'); return apiErrors.badRequest('Invalid duration value');
} }
+19 -10
View File
@@ -30,7 +30,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
project: true, project: true,
versions: { versions: {
orderBy: { versionNumber: 'desc' }, orderBy: { versionNumber: 'desc' },
...(includeComments ? { ...(includeComments
? {
include: { include: {
comments: { comments: {
orderBy: { timestamp: 'asc' }, orderBy: { timestamp: 'asc' },
@@ -85,7 +86,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}, },
_count: { select: { comments: true } }, _count: { select: { comments: true } },
}, },
} : { }
: {
select: { select: {
id: true, id: true,
thumbnailUrl: true, thumbnailUrl: true,
@@ -119,7 +121,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'VIEW', requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
if (!access.hasAccess && !shareAccess.hasAccess) { if (!access.hasAccess && !shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
@@ -141,9 +149,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
comments: version.comments.map((comment) => { comments: version.comments.map((comment) => {
const canEditComment = viewerUserId const canEditComment = viewerUserId
? comment.authorId === viewerUserId ? comment.authorId === viewerUserId
: !!viewerGuestIdentityId : !!viewerGuestIdentityId &&
&& !!comment.guestIdentityId !!comment.guestIdentityId &&
&& comment.guestIdentityId === viewerGuestIdentityId; comment.guestIdentityId === viewerGuestIdentityId;
const canDeleteComment = canEditComment || isProjectOwner; const canDeleteComment = canEditComment || isProjectOwner;
const replies = comment.replies; const replies = comment.replies;
const commentData = Object.fromEntries( const commentData = Object.fromEntries(
@@ -159,9 +167,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
replies: replies.map((reply) => { replies: replies.map((reply) => {
const canEditReply = viewerUserId const canEditReply = viewerUserId
? reply.authorId === viewerUserId ? reply.authorId === viewerUserId
: !!viewerGuestIdentityId : !!viewerGuestIdentityId &&
&& !!reply.guestIdentityId !!reply.guestIdentityId &&
&& reply.guestIdentityId === viewerGuestIdentityId; reply.guestIdentityId === viewerGuestIdentityId;
const canDeleteReply = canEditReply || isProjectOwner; const canDeleteReply = canEditReply || isProjectOwner;
const replyData = Object.fromEntries( const replyData = Object.fromEntries(
Object.entries(reply).filter( Object.entries(reply).filter(
@@ -180,7 +188,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}); });
const canCommentWithMembership = access.hasAccess; const canCommentWithMembership = access.hasAccess;
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); const canCommentWithShareLink =
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canDownloadWithMembership = access.hasAccess; const canDownloadWithMembership = access.hasAccess;
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload; const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink; const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
@@ -70,7 +70,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, passwordVerified: shareSession.passwordVerified,
}) })
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; : {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
const canCommentWithMembership = !!session?.user?.id && access.hasAccess; const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
const canCommentWithShareLink = shareAccess.canComment && shareAccess.allowGuests; const canCommentWithShareLink = shareAccess.canComment && shareAccess.allowGuests;
@@ -3,7 +3,11 @@ import { db } from '@/lib/db';
import { auth, checkWorkspaceAccess } from '@/lib/auth'; import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { InvitationRole, WorkspaceMemberRole } from '@prisma/client'; import { InvitationRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { buildInvitationUrl, createOrRefreshInvitation, sendInvitationEmail } from '@/lib/invitations'; import {
buildInvitationUrl,
createOrRefreshInvitation,
sendInvitationEmail,
} from '@/lib/invitations';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
@@ -110,16 +114,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
select: { id: true, name: true, email: true, image: true }, select: { id: true, name: true, email: true, image: true },
}); });
const response = successResponse( const response = successResponse({ members, owner, pendingInvitations }, 200, {
{ members, owner, pendingInvitations },
200,
{
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
} });
);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
logError('Error fetching workspace members:', error); logError('Error fetching workspace members:', error);
+3 -7
View File
@@ -61,16 +61,12 @@ export async function GET(request: NextRequest) {
db.workspace.count({ where }), db.workspace.count({ where }),
]); ]);
const response = successResponse( const response = successResponse({ workspaces }, 200, {
{ workspaces },
200,
{
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
} });
);
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
} catch (error) { } catch (error) {
logError('Error fetching workspaces:', error); logError('Error fetching workspaces:', error);
@@ -119,7 +115,7 @@ export async function POST(request: NextRequest) {
}); });
// Generate unique slug from the results // Generate unique slug from the results
const usedSlugs = new Set(existingWorkspaces.map(w => w.slug)); const usedSlugs = new Set(existingWorkspaces.map((w) => w.slug));
let slug = baseSlug; let slug = baseSlug;
let counter = 1; let counter = 1;
while (usedSlugs.has(slug)) { while (usedSlugs.has(slug)) {
+7 -11
View File
@@ -1,8 +1,8 @@
"use client"; 'use client';
import { useEffect } from "react"; import { useEffect } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from 'lucide-react';
export default function RootError({ export default function RootError({
error, error,
@@ -12,7 +12,7 @@ export default function RootError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
console.error("Root error:", error); console.error('Root error:', error);
}, [error]); }, [error]);
return ( return (
@@ -23,17 +23,13 @@ export default function RootError({
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
An unexpected error occurred. We&apos;ve been notified and are working to fix it. An unexpected error occurred. We&apos;ve been notified and are working to fix it.
</p> </p>
{error.digest && ( {error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
<p className="text-muted-foreground text-xs">
Error ID: {error.digest}
</p>
)}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={reset} variant="default"> <Button onClick={reset} variant="default">
Try again Try again
</Button> </Button>
<Button onClick={() => window.location.href = "/"} variant="outline"> <Button onClick={() => (window.location.href = '/')} variant="outline">
Go home Go home
</Button> </Button>
</div> </div>
+11 -11
View File
@@ -1,6 +1,6 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@import "shadcn/tailwind.css"; @import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -67,7 +67,7 @@
--input: oklch(0.923 0.003 48.717); --input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259); --ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.87 0.12 207); --chart-1: oklch(0.87 0.12 207);
--chart-2: oklch(0.80 0.13 212); --chart-2: oklch(0.8 0.13 212);
--chart-3: oklch(0.71 0.13 215); --chart-3: oklch(0.71 0.13 215);
--chart-4: oklch(0.61 0.11 222); --chart-4: oklch(0.61 0.11 222);
--chart-5: oklch(0.52 0.09 223); --chart-5: oklch(0.52 0.09 223);
@@ -90,28 +90,28 @@
--popover: oklch(0.216 0.006 56.043); --popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423); --popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.71 0.13 215); --primary: oklch(0.71 0.13 215);
--primary-foreground: oklch(0.30 0.05 230); --primary-foreground: oklch(0.3 0.05 230);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.268 0.007 34.298); --muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259); --muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.71 0.13 215); --accent: oklch(0.71 0.13 215);
--accent-foreground: oklch(0.30 0.05 230); --accent-foreground: oklch(0.3 0.05 230);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071); --ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.87 0.12 207); --chart-1: oklch(0.87 0.12 207);
--chart-2: oklch(0.80 0.13 212); --chart-2: oklch(0.8 0.13 212);
--chart-3: oklch(0.71 0.13 215); --chart-3: oklch(0.71 0.13 215);
--chart-4: oklch(0.61 0.11 222); --chart-4: oklch(0.61 0.11 222);
--chart-5: oklch(0.52 0.09 223); --chart-5: oklch(0.52 0.09 223);
--sidebar: oklch(0.216 0.006 56.043); --sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423); --sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.80 0.13 212); --sidebar-primary: oklch(0.8 0.13 212);
--sidebar-primary-foreground: oklch(0.30 0.05 230); --sidebar-primary-foreground: oklch(0.3 0.05 230);
--sidebar-accent: oklch(0.71 0.13 215); --sidebar-accent: oklch(0.71 0.13 215);
--sidebar-accent-foreground: oklch(0.30 0.05 230); --sidebar-accent-foreground: oklch(0.3 0.05 230);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071); --sidebar-ring: oklch(0.553 0.013 58.071);
} }
@@ -136,7 +136,7 @@
} }
.noise-overlay::before { .noise-overlay::before {
content: ""; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: var(--foreground); background: var(--foreground);
+35 -31
View File
@@ -1,9 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Geist_Mono, JetBrains_Mono } from "next/font/google"; import { Geist_Mono, JetBrains_Mono } from 'next/font/google';
import { Toaster } from "sonner"; import { Toaster } from 'sonner';
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from '@/components/theme-provider';
import { seoConfig } from "@/lib/seo"; import { seoConfig } from '@/lib/seo';
import "./globals.css"; import './globals.css';
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'], subsets: ['latin'],
@@ -31,20 +31,20 @@ export const metadata: Metadata = {
authors: [{ name: seoConfig.name, url: seoConfig.url }], authors: [{ name: seoConfig.name, url: seoConfig.url }],
creator: seoConfig.name, creator: seoConfig.name,
publisher: seoConfig.name, publisher: seoConfig.name,
category: "technology", category: 'technology',
referrer: "no-referrer", referrer: 'no-referrer',
alternates: { alternates: {
canonical: "/", canonical: '/',
}, },
icons: { icons: {
icon: [{ url: seoConfig.logo, type: "image/svg+xml" }], icon: [{ url: seoConfig.logo, type: 'image/svg+xml' }],
shortcut: [seoConfig.logo], shortcut: [seoConfig.logo],
apple: [{ url: seoConfig.logo }], apple: [{ url: seoConfig.logo }],
}, },
manifest: "/manifest.webmanifest", manifest: '/manifest.webmanifest',
openGraph: { openGraph: {
type: "website", type: 'website',
locale: "en_US", locale: 'en_US',
siteName: seoConfig.name, siteName: seoConfig.name,
url: seoConfig.url, url: seoConfig.url,
title: `${seoConfig.name} | ${seoConfig.title}`, title: `${seoConfig.name} | ${seoConfig.title}`,
@@ -59,7 +59,7 @@ export const metadata: Metadata = {
], ],
}, },
twitter: { twitter: {
card: "summary_large_image", card: 'summary_large_image',
title: `${seoConfig.name} | ${seoConfig.title}`, title: `${seoConfig.name} | ${seoConfig.title}`,
description: seoConfig.description, description: seoConfig.description,
images: [seoConfig.ogImage], images: [seoConfig.ogImage],
@@ -70,9 +70,9 @@ export const metadata: Metadata = {
googleBot: { googleBot: {
index: true, index: true,
follow: true, follow: true,
"max-image-preview": "large", 'max-image-preview': 'large',
"max-snippet": -1, 'max-snippet': -1,
"max-video-preview": -1, 'max-video-preview': -1,
}, },
}, },
formatDetection: { formatDetection: {
@@ -84,24 +84,24 @@ export const metadata: Metadata = {
const structuredData = [ const structuredData = [
{ {
"@context": "https://schema.org", '@context': 'https://schema.org',
"@type": "Organization", '@type': 'Organization',
name: seoConfig.name, name: seoConfig.name,
url: seoConfig.url, url: seoConfig.url,
logo: `${seoConfig.url}${seoConfig.logoPath}`, logo: `${seoConfig.url}${seoConfig.logoPath}`,
sameAs: [seoConfig.githubUrl], sameAs: [seoConfig.githubUrl],
}, },
{ {
"@context": "https://schema.org", '@context': 'https://schema.org',
"@type": "WebSite", '@type': 'WebSite',
name: seoConfig.name, name: seoConfig.name,
url: seoConfig.url, url: seoConfig.url,
description: seoConfig.description, description: seoConfig.description,
publisher: { publisher: {
"@type": "Organization", '@type': 'Organization',
name: seoConfig.name, name: seoConfig.name,
logo: { logo: {
"@type": "ImageObject", '@type': 'ImageObject',
url: `${seoConfig.url}${seoConfig.logoPath}`, url: `${seoConfig.url}${seoConfig.logoPath}`,
}, },
}, },
@@ -114,21 +114,25 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className={`${jetbrainsMono.variable} ${geistMono.variable}`} suppressHydrationWarning> <html
lang="en"
className={`${jetbrainsMono.variable} ${geistMono.variable}`}
suppressHydrationWarning
>
<body className="antialiased min-h-screen bg-background font-sans"> <body className="antialiased min-h-screen bg-background font-sans">
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
<ThemeProvider <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<svg aria-hidden="true" className="fixed h-0 w-0"> <svg aria-hidden="true" className="fixed h-0 w-0">
<filter id="openframe-noise"> <filter id="openframe-noise">
<feTurbulence type="fractalNoise" baseFrequency="0.92" numOctaves="2" stitchTiles="stitch" /> <feTurbulence
type="fractalNoise"
baseFrequency="0.92"
numOctaves="2"
stitchTiles="stitch"
/>
</filter> </filter>
</svg> </svg>
{children} {children}
+84 -37
View File
@@ -38,7 +38,12 @@ type Visibility = 'PRIVATE' | 'INVITE' | 'PUBLIC';
const TOTAL_STEPS = 5; const TOTAL_STEPS = 5;
const visibilityOptions: { value: Visibility; label: string; description: string; icon: React.ReactNode }[] = [ const visibilityOptions: {
value: Visibility;
label: string;
description: string;
icon: React.ReactNode;
}[] = [
{ {
value: 'PRIVATE', value: 'PRIVATE',
label: 'Private', label: 'Private',
@@ -81,12 +86,20 @@ function ToggleButton({
> >
<div className="flex-1 min-w-0 pr-4"> <div className="flex-1 min-w-0 pr-4">
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{label}</span>
{description && ( {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
</div> </div>
<div className={cn('w-10 h-6 shrink-0 rounded-full relative transition-colors', enabled ? 'bg-primary' : 'bg-muted')}> <div
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white transition-transform', enabled ? 'translate-x-5' : 'translate-x-1')} /> className={cn(
'w-10 h-6 shrink-0 rounded-full relative transition-colors',
enabled ? 'bg-primary' : 'bg-muted'
)}
>
<div
className={cn(
'absolute top-1 w-4 h-4 rounded-full bg-white transition-transform',
enabled ? 'translate-x-5' : 'translate-x-1'
)}
/>
</div> </div>
</button> </button>
); );
@@ -101,9 +114,12 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
<Video className="h-12 w-12 text-primary" /> <Video className="h-12 w-12 text-primary" />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight">Welcome to OpenFrame, {userName.split(' ')[0]}!</h2> <h2 className="text-3xl font-bold tracking-tight">
Welcome to OpenFrame, {userName.split(' ')[0]}!
</h2>
<p className="text-base text-muted-foreground max-w-md mx-auto"> <p className="text-base text-muted-foreground max-w-md mx-auto">
OpenFrame is your collaborative video review platform. Collect timestamped feedback, manage versions, and streamline approvals all in one place. OpenFrame is your collaborative video review platform. Collect timestamped feedback,
manage versions, and streamline approvals all in one place.
</p> </p>
</div> </div>
<Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base"> <Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
@@ -183,17 +199,15 @@ function StepWorkspace({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="onboarding-workspace">Choose a workspace</Label> <Label htmlFor="onboarding-workspace">Choose a workspace</Label>
<Select <Select value={selectedWorkspaceId ?? undefined} onValueChange={onWorkspaceSelected}>
value={selectedWorkspaceId ?? undefined}
onValueChange={onWorkspaceSelected}
>
<SelectTrigger id="onboarding-workspace" className="w-full"> <SelectTrigger id="onboarding-workspace" className="w-full">
<SelectValue placeholder="Select a workspace" /> <SelectValue placeholder="Select a workspace" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableWorkspaces.map((workspace) => ( {availableWorkspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}> <SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}{workspace.isOwner ? ' (Owner)' : ' (Admin)'} {workspace.name}
{workspace.isOwner ? ' (Owner)' : ' (Admin)'}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -210,7 +224,9 @@ function StepWorkspace({
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground"> <div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<span> <span>
You don&apos;t currently have a workspace where you can create projects. Ask a workspace owner to invite you as an admin, or upgrade later to create your own workspace. You don&apos;t currently have a workspace where you can create projects. Ask a
workspace owner to invite you as an admin, or upgrade later to create your own
workspace.
</span> </span>
</div> </div>
<Button onClick={onNext} className="w-full h-11"> <Button onClick={onNext} className="w-full h-11">
@@ -273,7 +289,11 @@ function StepWorkspace({
)} )}
<div className="flex flex-col gap-2 pt-1"> <div className="flex flex-col gap-2 pt-1">
<Button type="submit" className="w-full h-11" disabled={isLoading || !formData.name.trim()}> <Button
type="submit"
className="w-full h-11"
disabled={isLoading || !formData.name.trim()}
>
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -283,7 +303,13 @@ function StepWorkspace({
'Create Workspace' 'Create Workspace'
)} )}
</Button> </Button>
<Button type="button" variant="ghost" onClick={onNext} disabled={isLoading} className="w-full text-muted-foreground"> <Button
type="button"
variant="ghost"
onClick={onNext}
disabled={isLoading}
className="w-full text-muted-foreground"
>
Skip this step Skip this step
</Button> </Button>
</div> </div>
@@ -416,22 +442,28 @@ function StepProject({
: 'border-border hover:border-border/80 hover:bg-accent/50' : 'border-border hover:border-border/80 hover:bg-accent/50'
)} )}
> >
<div className={cn( <div
className={cn(
'shrink-0 w-10 h-10 rounded-lg flex items-center justify-center', 'shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
formData.visibility === option.value formData.visibility === option.value
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
)}> )}
>
{option.icon} {option.icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium">{option.label}</div> <div className="font-medium">{option.label}</div>
<div className="text-sm text-muted-foreground">{option.description}</div> <div className="text-sm text-muted-foreground">{option.description}</div>
</div> </div>
<div className={cn( <div
className={cn(
'shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center', 'shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center',
formData.visibility === option.value ? 'border-primary bg-primary' : 'border-muted-foreground/30' formData.visibility === option.value
)}> ? 'border-primary bg-primary'
: 'border-muted-foreground/30'
)}
>
{formData.visibility === option.value && ( {formData.visibility === option.value && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" /> <div className="w-2 h-2 rounded-full bg-primary-foreground" />
)} )}
@@ -449,7 +481,11 @@ function StepProject({
)} )}
<div className="flex flex-col gap-2 pt-1"> <div className="flex flex-col gap-2 pt-1">
<Button type="submit" className="w-full h-11" disabled={isLoading || !formData.name.trim()}> <Button
type="submit"
className="w-full h-11"
disabled={isLoading || !formData.name.trim()}
>
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -459,7 +495,13 @@ function StepProject({
'Create Project' 'Create Project'
)} )}
</Button> </Button>
<Button type="button" variant="ghost" onClick={onNext} disabled={isLoading} className="w-full text-muted-foreground"> <Button
type="button"
variant="ghost"
onClick={onNext}
disabled={isLoading}
className="w-full text-muted-foreground"
>
Skip this step Skip this step
</Button> </Button>
</div> </div>
@@ -491,7 +533,8 @@ function StepVideo({ onNext }: { onNext: () => void }) {
YouTube link YouTube link
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Paste a link to any YouTube video. OpenFrame will pull in the title, thumbnail, and duration automatically no file upload needed. Paste a link to any YouTube video. OpenFrame will pull in the title, thumbnail, and
duration automatically no file upload needed.
</p> </p>
</div> </div>
<div className="rounded-xl border p-5 space-y-2"> <div className="rounded-xl border p-5 space-y-2">
@@ -500,7 +543,8 @@ function StepVideo({ onNext }: { onNext: () => void }) {
Direct upload Direct upload
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Upload video files directly from your device. Files are processed and delivered via CDN for fast, reliable playback worldwide. Upload video files directly from your device. Files are processed and delivered via CDN
for fast, reliable playback worldwide.
</p> </p>
</div> </div>
</div> </div>
@@ -559,7 +603,8 @@ function StepNotifications({ onFinish }: { onFinish: () => Promise<void> }) {
</div> </div>
<h2 className="text-xl font-bold tracking-tight">Notification preferences</h2> <h2 className="text-xl font-bold tracking-tight">Notification preferences</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Choose when you want to be notified. Email and Telegram are both supported you can configure Telegram anytime in Settings. Choose when you want to be notified. Email and Telegram are both supported you can
configure Telegram anytime in Settings.
</p> </p>
</div> </div>
@@ -626,7 +671,13 @@ function StepNotifications({ onFinish }: { onFinish: () => Promise<void> }) {
'Save & finish' 'Save & finish'
)} )}
</Button> </Button>
<Button type="button" variant="ghost" onClick={onFinish} disabled={isSaving} className="w-full text-muted-foreground"> <Button
type="button"
variant="ghost"
onClick={onFinish}
disabled={isSaving}
className="w-full text-muted-foreground"
>
Skip Skip
</Button> </Button>
</div> </div>
@@ -647,7 +698,9 @@ export function OnboardingWizard({
}) { }) {
const router = useRouter(); const router = useRouter();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [createdWorkspaceId, setCreatedWorkspaceId] = useState<string | null>(availableWorkspaces[0]?.id ?? null); const [createdWorkspaceId, setCreatedWorkspaceId] = useState<string | null>(
availableWorkspaces[0]?.id ?? null
);
const [isCompleting, setIsCompleting] = useState(false); const [isCompleting, setIsCompleting] = useState(false);
const goNext = () => setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS)); const goNext = () => setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS));
@@ -708,9 +761,7 @@ export function OnboardingWizard({
{/* Step content */} {/* Step content */}
<Card className="border-border/50 shadow-lg"> <Card className="border-border/50 shadow-lg">
<CardContent className="pt-10 pb-10 px-10"> <CardContent className="pt-10 pb-10 px-10">
{currentStep === 1 && ( {currentStep === 1 && <StepWelcome userName={userName} onNext={goNext} />}
<StepWelcome userName={userName} onNext={goNext} />
)}
{currentStep === 2 && ( {currentStep === 2 && (
<StepWorkspace <StepWorkspace
canCreateWorkspace={canCreateWorkspace} canCreateWorkspace={canCreateWorkspace}
@@ -730,12 +781,8 @@ export function OnboardingWizard({
onProjectCreated={() => {}} onProjectCreated={() => {}}
/> />
)} )}
{currentStep === 4 && ( {currentStep === 4 && <StepVideo onNext={goNext} />}
<StepVideo onNext={goNext} /> {currentStep === 5 && <StepNotifications onFinish={completeOnboarding} />}
)}
{currentStep === 5 && (
<StepNotifications onFinish={completeOnboarding} />
)}
</CardContent> </CardContent>
</Card> </Card>
+193 -50
View File
@@ -12,11 +12,17 @@ export default function PrivacyPolicyPage() {
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8"> <header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[900px] items-center justify-between"> <div className="mx-auto flex max-w-[900px] items-center justify-between">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"> <Link
href="/"
className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
>
<Video className="h-4 w-4 text-primary" /> <Video className="h-4 w-4 text-primary" />
OpenFrame OpenFrame
</Link> </Link>
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors"> <Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to Home Back to Home
</Link> </Link>
</div> </div>
@@ -27,49 +33,93 @@ export default function PrivacyPolicyPage() {
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p> <p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80"> <div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">1. Introduction</h2> <h2 className="text-base font-semibold text-foreground mb-3">1. Introduction</h2>
<p> <p>
<strong className="text-foreground">IPEK TECH LLC</strong> (&ldquo;Company&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;, or &ldquo;our&rdquo;), a Wyoming limited liability company, operates the OpenFrame platform at open-frame.net (the &ldquo;Service&rdquo;). This Privacy Policy explains how we collect, use, share, and protect information about you when you use our Service. <strong className="text-foreground">IPEK TECH LLC</strong> (&ldquo;Company&rdquo;,
&ldquo;we&rdquo;, &ldquo;us&rdquo;, or &ldquo;our&rdquo;), a Wyoming limited liability
company, operates the OpenFrame platform at open-frame.net (the
&ldquo;Service&rdquo;). This Privacy Policy explains how we collect, use, share, and
protect information about you when you use our Service.
</p> </p>
<p className="mt-3"> <p className="mt-3">
By using the Service, you agree to the collection and use of information in accordance with this Privacy Policy. By using the Service, you agree to the collection and use of information in accordance
with this Privacy Policy.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">2. Information We Collect</h2> <h2 className="text-base font-semibold text-foreground mb-3">
2. Information We Collect
</h2>
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">2.1 Information You Provide</h3> <h3 className="text-sm font-semibold text-foreground mb-2 mt-4">
2.1 Information You Provide
</h3>
<ul className="list-disc pl-5 space-y-2"> <ul className="list-disc pl-5 space-y-2">
<li><strong className="text-foreground">Account information:</strong> Name, email address, and password when you register.</li> <li>
<li><strong className="text-foreground">Profile information:</strong> Avatar image and display name.</li> <strong className="text-foreground">Account information:</strong> Name, email
<li><strong className="text-foreground">Billing information:</strong> Payment details processed securely through Stripe. We do not store full card numbers on our servers.</li> address, and password when you register.
<li><strong className="text-foreground">User Content:</strong> Videos, comments, annotations, and other content you upload or create within the Service.</li> </li>
<li><strong className="text-foreground">Communications:</strong> Messages you send us via email or feedback forms.</li> <li>
<strong className="text-foreground">Profile information:</strong> Avatar image and
display name.
</li>
<li>
<strong className="text-foreground">Billing information:</strong> Payment details
processed securely through Stripe. We do not store full card numbers on our servers.
</li>
<li>
<strong className="text-foreground">User Content:</strong> Videos, comments,
annotations, and other content you upload or create within the Service.
</li>
<li>
<strong className="text-foreground">Communications:</strong> Messages you send us
via email or feedback forms.
</li>
</ul> </ul>
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">2.2 Information Collected Automatically</h3> <h3 className="text-sm font-semibold text-foreground mb-2 mt-4">
2.2 Information Collected Automatically
</h3>
<ul className="list-disc pl-5 space-y-2"> <ul className="list-disc pl-5 space-y-2">
<li><strong className="text-foreground">Usage data:</strong> Pages viewed, features used, actions taken within the Service, and timestamps.</li> <li>
<li><strong className="text-foreground">Device and browser data:</strong> IP address, browser type, operating system, and referring URLs.</li> <strong className="text-foreground">Usage data:</strong> Pages viewed, features
<li><strong className="text-foreground">Cookies and similar technologies:</strong> Session cookies for authentication and preference storage. We do not use third-party advertising cookies.</li> used, actions taken within the Service, and timestamps.
</li>
<li>
<strong className="text-foreground">Device and browser data:</strong> IP address,
browser type, operating system, and referring URLs.
</li>
<li>
<strong className="text-foreground">Cookies and similar technologies:</strong>{' '}
Session cookies for authentication and preference storage. We do not use third-party
advertising cookies.
</li>
</ul> </ul>
<h3 className="text-sm font-semibold text-foreground mb-2 mt-4">2.3 Information from Third Parties</h3> <h3 className="text-sm font-semibold text-foreground mb-2 mt-4">
2.3 Information from Third Parties
</h3>
<p> <p>
If you sign in via a third-party OAuth provider (Google or GitHub), we receive basic profile information (name, email, avatar) as permitted by your settings with that provider. If you sign in via a third-party OAuth provider (Google or GitHub), we receive basic
profile information (name, email, avatar) as permitted by your settings with that
provider.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">3. How We Use Your Information</h2> <h2 className="text-base font-semibold text-foreground mb-3">
3. How We Use Your Information
</h2>
<p>We use the information we collect to:</p> <p>We use the information we collect to:</p>
<ul className="mt-3 list-disc pl-5 space-y-2"> <ul className="mt-3 list-disc pl-5 space-y-2">
<li>Provide, operate, and improve the Service.</li> <li>Provide, operate, and improve the Service.</li>
<li>Process transactions and manage your subscription.</li> <li>Process transactions and manage your subscription.</li>
<li>Send transactional emails (account confirmations, password resets, billing notifications).</li> <li>
Send transactional emails (account confirmations, password resets, billing
notifications).
</li>
<li>Respond to your inquiries and support requests.</li> <li>Respond to your inquiries and support requests.</li>
<li>Send product updates or announcements (you may opt out at any time).</li> <li>Send product updates or announcements (you may opt out at any time).</li>
<li>Monitor and analyze usage patterns to improve the Service.</li> <li>Monitor and analyze usage patterns to improve the Service.</li>
@@ -79,103 +129,196 @@ export default function PrivacyPolicyPage() {
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">4. How We Share Your Information</h2> <h2 className="text-base font-semibold text-foreground mb-3">
4. How We Share Your Information
</h2>
<p>We do not sell your personal information. We may share your information with:</p> <p>We do not sell your personal information. We may share your information with:</p>
<ul className="mt-3 list-disc pl-5 space-y-2"> <ul className="mt-3 list-disc pl-5 space-y-2">
<li><strong className="text-foreground">Service providers:</strong> Third parties who assist us in operating the Service (e.g., cloud storage, video delivery, payment processing via Stripe). These providers are contractually bound to protect your data.</li> <li>
<li><strong className="text-foreground">Other users:</strong> User Content you choose to share via share links is accessible to recipients of those links per the permissions you configure.</li> <strong className="text-foreground">Service providers:</strong> Third parties who
<li><strong className="text-foreground">Legal requirements:</strong> We may disclose information if required by law, court order, or governmental authority, or to protect the rights and safety of IPEK TECH LLC or others.</li> assist us in operating the Service (e.g., cloud storage, video delivery, payment
<li><strong className="text-foreground">Business transfers:</strong> In the event of a merger, acquisition, or sale of assets, your information may be transferred as part of the transaction.</li> processing via Stripe). These providers are contractually bound to protect your
data.
</li>
<li>
<strong className="text-foreground">Other users:</strong> User Content you choose to
share via share links is accessible to recipients of those links per the permissions
you configure.
</li>
<li>
<strong className="text-foreground">Legal requirements:</strong> We may disclose
information if required by law, court order, or governmental authority, or to
protect the rights and safety of IPEK TECH LLC or others.
</li>
<li>
<strong className="text-foreground">Business transfers:</strong> In the event of a
merger, acquisition, or sale of assets, your information may be transferred as part
of the transaction.
</li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">5. Data Retention</h2> <h2 className="text-base font-semibold text-foreground mb-3">5. Data Retention</h2>
<p> <p>
We retain your personal information for as long as your account is active or as needed to provide the Service. If you delete your account, we will delete or anonymize your personal information within a reasonable period, except where we are required to retain it for legal, regulatory, or legitimate business purposes (such as billing disputes). We retain your personal information for as long as your account is active or as needed
to provide the Service. If you delete your account, we will delete or anonymize your
personal information within a reasonable period, except where we are required to
retain it for legal, regulatory, or legitimate business purposes (such as billing
disputes).
</p> </p>
<p className="mt-3"> <p className="mt-3">
User Content you delete from the Service will be removed from our active storage; however, backup copies may persist for a limited time before being purged. User Content you delete from the Service will be removed from our active storage;
however, backup copies may persist for a limited time before being purged.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">6. Security</h2> <h2 className="text-base font-semibold text-foreground mb-3">6. Security</h2>
<p> <p>
We implement industry-standard security measures to protect your information, including encryption in transit (TLS) and access controls. However, no method of transmission over the internet or electronic storage is 100% secure. We cannot guarantee absolute security and encourage you to use strong, unique passwords and to keep your account credentials confidential. We implement industry-standard security measures to protect your information,
including encryption in transit (TLS) and access controls. However, no method of
transmission over the internet or electronic storage is 100% secure. We cannot
guarantee absolute security and encourage you to use strong, unique passwords and to
keep your account credentials confidential.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">7. Your Rights and Choices</h2> <h2 className="text-base font-semibold text-foreground mb-3">
<p>Depending on your location, you may have rights regarding your personal information, including:</p> 7. Your Rights and Choices
</h2>
<p>
Depending on your location, you may have rights regarding your personal information,
including:
</p>
<ul className="mt-3 list-disc pl-5 space-y-2"> <ul className="mt-3 list-disc pl-5 space-y-2">
<li><strong className="text-foreground">Access and portability:</strong> Request a copy of the data we hold about you.</li> <li>
<li><strong className="text-foreground">Correction:</strong> Request correction of inaccurate data.</li> <strong className="text-foreground">Access and portability:</strong> Request a copy
<li><strong className="text-foreground">Deletion:</strong> Request deletion of your personal information (subject to legal retention requirements).</li> of the data we hold about you.
<li><strong className="text-foreground">Opt-out of marketing:</strong> Unsubscribe from marketing emails at any time via the unsubscribe link in any email or by contacting us.</li> </li>
<li>
<strong className="text-foreground">Correction:</strong> Request correction of
inaccurate data.
</li>
<li>
<strong className="text-foreground">Deletion:</strong> Request deletion of your
personal information (subject to legal retention requirements).
</li>
<li>
<strong className="text-foreground">Opt-out of marketing:</strong> Unsubscribe from
marketing emails at any time via the unsubscribe link in any email or by contacting
us.
</li>
</ul> </ul>
<p className="mt-3"> <p className="mt-3">
To exercise these rights, contact us at <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a>. We will respond within a reasonable timeframe. To exercise these rights, contact us at{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>
. We will respond within a reasonable timeframe.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">8. Cookies</h2> <h2 className="text-base font-semibold text-foreground mb-3">8. Cookies</h2>
<p> <p>
We use cookies strictly necessary for the operation of the Service (authentication sessions, CSRF protection) and limited analytics cookies to understand how the Service is used. We do not use third-party advertising cookies or tracking pixels. You may disable cookies in your browser settings, but doing so may affect your ability to use the Service. We use cookies strictly necessary for the operation of the Service (authentication
sessions, CSRF protection) and limited analytics cookies to understand how the Service
is used. We do not use third-party advertising cookies or tracking pixels. You may
disable cookies in your browser settings, but doing so may affect your ability to use
the Service.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">9. Children&apos;s Privacy</h2> <h2 className="text-base font-semibold text-foreground mb-3">
9. Children&apos;s Privacy
</h2>
<p> <p>
The Service is not directed to individuals under the age of 18. We do not knowingly collect personal information from minors. If you believe we have inadvertently collected information from a minor, please contact us immediately at <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a> and we will take steps to delete such information. The Service is not directed to individuals under the age of 18. We do not knowingly
collect personal information from minors. If you believe we have inadvertently
collected information from a minor, please contact us immediately at{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>{' '}
and we will take steps to delete such information.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">10. International Data Transfers</h2> <h2 className="text-base font-semibold text-foreground mb-3">
10. International Data Transfers
</h2>
<p> <p>
Your information may be stored and processed in the United States or other countries where our service providers operate. By using the Service, you consent to the transfer of your information to these locations, which may have different data protection laws than your country of residence. Your information may be stored and processed in the United States or other countries
where our service providers operate. By using the Service, you consent to the transfer
of your information to these locations, which may have different data protection laws
than your country of residence.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">11. Third-Party Services</h2> <h2 className="text-base font-semibold text-foreground mb-3">
11. Third-Party Services
</h2>
<p> <p>
The Service may integrate with or link to third-party services (e.g., GitHub, Google, Stripe, Bunny CDN). This Privacy Policy does not apply to those services, and we encourage you to review their respective privacy policies. The Service may integrate with or link to third-party services (e.g., GitHub, Google,
Stripe, Bunny CDN). This Privacy Policy does not apply to those services, and we
encourage you to review their respective privacy policies.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">12. Changes to This Policy</h2> <h2 className="text-base font-semibold text-foreground mb-3">
12. Changes to This Policy
</h2>
<p> <p>
We may update this Privacy Policy from time to time. We will notify you of material changes by posting the updated policy on this page and updating the &ldquo;Last updated&rdquo; date. Your continued use of the Service after changes constitutes acceptance of the updated policy. We may update this Privacy Policy from time to time. We will notify you of material
changes by posting the updated policy on this page and updating the &ldquo;Last
updated&rdquo; date. Your continued use of the Service after changes constitutes
acceptance of the updated policy.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">13. Contact Us</h2> <h2 className="text-base font-semibold text-foreground mb-3">13. Contact Us</h2>
<p> <p>
If you have any questions or concerns about this Privacy Policy or our data practices, please contact us: If you have any questions or concerns about this Privacy Policy or our data practices,
please contact us:
</p> </p>
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1"> <div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
<p className="font-medium text-foreground">IPEK TECH LLC</p> <p className="font-medium text-foreground">IPEK TECH LLC</p>
<p>Wyoming, United States</p> <p>Wyoming, United States</p>
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p> <p>
Email:{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>
</p>
</div> </div>
</section> </section>
</div> </div>
</main> </main>
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8"> <footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[900px] items-center justify-between"> <div className="mx-auto flex max-w-[900px] items-center justify-between">
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span> <span className="font-mono text-xs text-muted-foreground">
© 2026 IPEK TECH LLC. All rights reserved.
</span>
<div className="flex gap-4"> <div className="flex gap-4">
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Terms of Service</Link> <Link
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Refund Policy</Link> href="/terms"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Terms of Service
</Link>
<Link
href="/refund"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Refund Policy
</Link>
</div> </div>
</div> </div>
</footer> </footer>
+102 -32
View File
@@ -12,11 +12,17 @@ export default function RefundPolicyPage() {
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8"> <header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[900px] items-center justify-between"> <div className="mx-auto flex max-w-[900px] items-center justify-between">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"> <Link
href="/"
className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
>
<Video className="h-4 w-4 text-primary" /> <Video className="h-4 w-4 text-primary" />
OpenFrame OpenFrame
</Link> </Link>
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors"> <Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to Home Back to Home
</Link> </Link>
</div> </div>
@@ -27,28 +33,38 @@ export default function RefundPolicyPage() {
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p> <p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80"> <div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">1. Overview</h2> <h2 className="text-base font-semibold text-foreground mb-3">1. Overview</h2>
<p> <p>
This Refund Policy applies to all paid subscriptions to the OpenFrame platform operated by <strong className="text-foreground">IPEK TECH LLC</strong>, a Wyoming limited liability company. By subscribing, you acknowledge and agree to this policy. This Refund Policy applies to all paid subscriptions to the OpenFrame platform
operated by <strong className="text-foreground">IPEK TECH LLC</strong>, a Wyoming
limited liability company. By subscribing, you acknowledge and agree to this policy.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">2. Free Trial</h2> <h2 className="text-base font-semibold text-foreground mb-3">2. Free Trial</h2>
<p> <p>
All new accounts are eligible for a <strong className="text-foreground">7-day free trial</strong> with full access to paid features. We strongly encourage you to evaluate the Service fully during this period before subscribing. All new accounts are eligible for a{' '}
<strong className="text-foreground">7-day free trial</strong> with full access to paid
features. We strongly encourage you to evaluate the Service fully during this period
before subscribing.
</p> </p>
<p className="mt-3"> <p className="mt-3">
You may cancel at any time during your free trial without being charged. If you do not cancel before the trial ends, your chosen plan will automatically activate and payment will be collected. You may cancel at any time during your free trial without being charged. If you do not
cancel before the trial ends, your chosen plan will automatically activate and payment
will be collected.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">3. General No-Refund Policy</h2> <h2 className="text-base font-semibold text-foreground mb-3">
3. General No-Refund Policy
</h2>
<p> <p>
Because we offer a full-featured free trial, <strong className="text-foreground">all subscription fees are non-refundable</strong> once charged. This includes: Because we offer a full-featured free trial,{' '}
<strong className="text-foreground">all subscription fees are non-refundable</strong>{' '}
once charged. This includes:
</p> </p>
<ul className="mt-3 list-disc pl-5 space-y-2"> <ul className="mt-3 list-disc pl-5 space-y-2">
<li>Monthly subscription charges</li> <li>Monthly subscription charges</li>
@@ -57,77 +73,131 @@ export default function RefundPolicyPage() {
<li>Any other paid feature or upgrade</li> <li>Any other paid feature or upgrade</li>
</ul> </ul>
<p className="mt-3"> <p className="mt-3">
Canceling your subscription stops future billing but does not entitle you to a refund for the current billing period. You will continue to have access to the Service until the end of your current paid period. Canceling your subscription stops future billing but does not entitle you to a refund
for the current billing period. You will continue to have access to the Service until
the end of your current paid period.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">4. Exceptions Extreme Circumstances</h2> <h2 className="text-base font-semibold text-foreground mb-3">
4. Exceptions Extreme Circumstances
</h2>
<p> <p>
Refunds may be considered <strong className="text-foreground">only in exceptional circumstances</strong>, at the sole discretion of IPEK TECH LLC. Circumstances that <em>may</em> qualify include: Refunds may be considered{' '}
<strong className="text-foreground">only in exceptional circumstances</strong>, at the
sole discretion of IPEK TECH LLC. Circumstances that <em>may</em> qualify include:
</p> </p>
<ul className="mt-3 list-disc pl-5 space-y-2"> <ul className="mt-3 list-disc pl-5 space-y-2">
<li><strong className="text-foreground">Extended platform outage:</strong> A verified, prolonged service disruption (greater than 72 consecutive hours) caused by our infrastructure that rendered the Service completely unusable during a billing period.</li> <li>
<li><strong className="text-foreground">Duplicate charge:</strong> A billing error that resulted in you being charged more than once for the same subscription period.</li> <strong className="text-foreground">Extended platform outage:</strong> A verified,
<li><strong className="text-foreground">Unauthorized transaction:</strong> A charge made to your account that you did not authorize and that was reported to us promptly (within 14 days of the charge).</li> prolonged service disruption (greater than 72 consecutive hours) caused by our
infrastructure that rendered the Service completely unusable during a billing
period.
</li>
<li>
<strong className="text-foreground">Duplicate charge:</strong> A billing error that
resulted in you being charged more than once for the same subscription period.
</li>
<li>
<strong className="text-foreground">Unauthorized transaction:</strong> A charge made
to your account that you did not authorize and that was reported to us promptly
(within 14 days of the charge).
</li>
</ul> </ul>
<p className="mt-3 border-l-2 border-border pl-4 text-muted-foreground"> <p className="mt-3 border-l-2 border-border pl-4 text-muted-foreground">
Dissatisfaction with the product, a change in business circumstances, forgetting to cancel before renewal, or failure to use the Service during a billing period are not considered exceptional circumstances and do not qualify for a refund. Dissatisfaction with the product, a change in business circumstances, forgetting to
cancel before renewal, or failure to use the Service during a billing period are not
considered exceptional circumstances and do not qualify for a refund.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">5. How to Request a Refund</h2> <h2 className="text-base font-semibold text-foreground mb-3">
5. How to Request a Refund
</h2>
<p> <p>
If you believe your situation qualifies as an exceptional circumstance, contact us within <strong className="text-foreground">14 days</strong> of the charge in question: If you believe your situation qualifies as an exceptional circumstance, contact us
within <strong className="text-foreground">14 days</strong> of the charge in question:
</p> </p>
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1"> <div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p> <p>
<p>Subject line: <span className="font-mono text-xs">Refund Request [your account email]</span></p> Email:{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>
</p>
<p>
Subject line:{' '}
<span className="font-mono text-xs">Refund Request [your account email]</span>
</p>
</div> </div>
<p className="mt-3"> <p className="mt-3">
Please include: your registered email address, the date of the charge, the amount charged, and a description of the circumstances. We will review your request and respond within 5 business days. Please include: your registered email address, the date of the charge, the amount
charged, and a description of the circumstances. We will review your request and
respond within 5 business days.
</p> </p>
<p className="mt-3"> <p className="mt-3">
Approved refunds will be issued to the original payment method and may take 510 business days to appear depending on your bank or card issuer. Approved refunds will be issued to the original payment method and may take 510
business days to appear depending on your bank or card issuer.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">6. Chargebacks</h2> <h2 className="text-base font-semibold text-foreground mb-3">6. Chargebacks</h2>
<p> <p>
Filing a chargeback with your bank or payment provider without first contacting us to resolve the issue may result in immediate suspension of your account. We reserve the right to dispute chargebacks that are inconsistent with this Refund Policy. Filing a chargeback with your bank or payment provider without first contacting us to
resolve the issue may result in immediate suspension of your account. We reserve the
right to dispute chargebacks that are inconsistent with this Refund Policy.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">7. Changes to This Policy</h2> <h2 className="text-base font-semibold text-foreground mb-3">
7. Changes to This Policy
</h2>
<p> <p>
We reserve the right to modify this Refund Policy at any time. Material changes will be communicated via the Service or by email. Your continued use of the Service after changes constitutes your acceptance of the updated policy. We reserve the right to modify this Refund Policy at any time. Material changes will
be communicated via the Service or by email. Your continued use of the Service after
changes constitutes your acceptance of the updated policy.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">8. Contact</h2> <h2 className="text-base font-semibold text-foreground mb-3">8. Contact</h2>
<p> <p>For billing questions or refund requests:</p>
For billing questions or refund requests:
</p>
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1"> <div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
<p className="font-medium text-foreground">IPEK TECH LLC</p> <p className="font-medium text-foreground">IPEK TECH LLC</p>
<p>Wyoming, United States</p> <p>Wyoming, United States</p>
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p> <p>
Email:{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>
</p>
</div> </div>
</section> </section>
</div> </div>
</main> </main>
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8"> <footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[900px] items-center justify-between"> <div className="mx-auto flex max-w-[900px] items-center justify-between">
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span> <span className="font-mono text-xs text-muted-foreground">
© 2026 IPEK TECH LLC. All rights reserved.
</span>
<div className="flex gap-4"> <div className="flex gap-4">
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Terms of Service</Link> <Link
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Privacy Policy</Link> href="/terms"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Terms of Service
</Link>
<Link
href="/privacy"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
</div> </div>
</div> </div>
</footer> </footer>
+1 -3
View File
@@ -22,9 +22,7 @@ export default function SignOutPage() {
<LogOut className="h-6 w-6 text-muted-foreground" /> <LogOut className="h-6 w-6 text-muted-foreground" />
</div> </div>
<CardTitle className="text-2xl">Sign out</CardTitle> <CardTitle className="text-2xl">Sign out</CardTitle>
<CardDescription> <CardDescription>Are you sure you want to sign out?</CardDescription>
Are you sure you want to sign out?
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<Button onClick={handleSignOut} disabled={loading} className="w-full"> <Button onClick={handleSignOut} disabled={loading} className="w-full">
+162 -43
View File
@@ -12,11 +12,17 @@ export default function TermsOfServicePage() {
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8"> <header className="border-b border-border px-4 py-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[900px] items-center justify-between"> <div className="mx-auto flex max-w-[900px] items-center justify-between">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"> <Link
href="/"
className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
>
<Video className="h-4 w-4 text-primary" /> <Video className="h-4 w-4 text-primary" />
OpenFrame OpenFrame
</Link> </Link>
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors"> <Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to Home Back to Home
</Link> </Link>
</div> </div>
@@ -27,64 +33,108 @@ export default function TermsOfServicePage() {
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p> <p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80"> <div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">1. Agreement to Terms</h2> <h2 className="text-base font-semibold text-foreground mb-3">1. Agreement to Terms</h2>
<p> <p>
These Terms of Service (&ldquo;Terms&rdquo;) constitute a legally binding agreement between you (&ldquo;User&rdquo;, &ldquo;you&rdquo;, or &ldquo;your&rdquo;) and <strong className="text-foreground">IPEK TECH LLC</strong>, a Wyoming limited liability company (&ldquo;Company&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;, or &ldquo;our&rdquo;), governing your access to and use of the OpenFrame platform, available at open-frame.net (the &ldquo;Service&rdquo;). These Terms of Service (&ldquo;Terms&rdquo;) constitute a legally binding agreement
between you (&ldquo;User&rdquo;, &ldquo;you&rdquo;, or &ldquo;your&rdquo;) and{' '}
<strong className="text-foreground">IPEK TECH LLC</strong>, a Wyoming limited
liability company (&ldquo;Company&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;, or
&ldquo;our&rdquo;), governing your access to and use of the OpenFrame platform,
available at open-frame.net (the &ldquo;Service&rdquo;).
</p> </p>
<p className="mt-3"> <p className="mt-3">
By creating an account or accessing the Service in any manner, you agree to be bound by these Terms. If you do not agree to these Terms, you may not access or use the Service. By creating an account or accessing the Service in any manner, you agree to be bound
by these Terms. If you do not agree to these Terms, you may not access or use the
Service.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">2. Description of Service</h2> <h2 className="text-base font-semibold text-foreground mb-3">
2. Description of Service
</h2>
<p> <p>
OpenFrame is a video review and approval platform that enables creative professionals and their clients to collaborate on video projects through timestamped comments, annotations, version management, and approval workflows. The Service is offered on a subscription basis with a free trial period. OpenFrame is a video review and approval platform that enables creative professionals
and their clients to collaborate on video projects through timestamped comments,
annotations, version management, and approval workflows. The Service is offered on a
subscription basis with a free trial period.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">3. Eligibility</h2> <h2 className="text-base font-semibold text-foreground mb-3">3. Eligibility</h2>
<p> <p>
You must be at least 18 years old to use the Service. By using the Service, you represent that you are at least 18 years of age and have the legal authority to enter into these Terms. If you are using the Service on behalf of an organization, you represent that you have authority to bind that organization to these Terms. You must be at least 18 years old to use the Service. By using the Service, you
represent that you are at least 18 years of age and have the legal authority to enter
into these Terms. If you are using the Service on behalf of an organization, you
represent that you have authority to bind that organization to these Terms.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">4. Accounts</h2> <h2 className="text-base font-semibold text-foreground mb-3">4. Accounts</h2>
<p> <p>
To access most features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during registration and to keep your account information updated. You are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account. To access most features of the Service, you must register for an account. You agree to
provide accurate, current, and complete information during registration and to keep
your account information updated. You are responsible for maintaining the
confidentiality of your account credentials and for all activities that occur under
your account.
</p> </p>
<p className="mt-3"> <p className="mt-3">
You agree to notify us immediately at <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a> of any unauthorized use of your account. We are not liable for any losses arising from unauthorized use of your account. You agree to notify us immediately at{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>{' '}
of any unauthorized use of your account. We are not liable for any losses arising from
unauthorized use of your account.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">5. Subscriptions and Free Trial</h2> <h2 className="text-base font-semibold text-foreground mb-3">
5. Subscriptions and Free Trial
</h2>
<p> <p>
Certain features of the Service require a paid subscription. We offer a <strong className="text-foreground">7-day free trial</strong> for new accounts, during which you may access paid features at no charge. At the end of the trial period, your subscription will automatically convert to a paid plan unless you cancel before the trial ends. Certain features of the Service require a paid subscription. We offer a{' '}
<strong className="text-foreground">7-day free trial</strong> for new accounts, during
which you may access paid features at no charge. At the end of the trial period, your
subscription will automatically convert to a paid plan unless you cancel before the
trial ends.
</p> </p>
<p className="mt-3"> <p className="mt-3">
Subscription fees are billed in advance on a monthly or annual basis depending on the plan you select. All fees are non-refundable except as expressly stated in our <Link href="/refund" className="text-primary hover:underline">Refund Policy</Link>. Subscription fees are billed in advance on a monthly or annual basis depending on the
plan you select. All fees are non-refundable except as expressly stated in our{' '}
<Link href="/refund" className="text-primary hover:underline">
Refund Policy
</Link>
.
</p> </p>
<p className="mt-3"> <p className="mt-3">
We reserve the right to change subscription pricing with reasonable advance notice. Continued use of the Service after a price change constitutes your agreement to the new pricing. We reserve the right to change subscription pricing with reasonable advance notice.
Continued use of the Service after a price change constitutes your agreement to the
new pricing.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">6. User Content</h2> <h2 className="text-base font-semibold text-foreground mb-3">6. User Content</h2>
<p> <p>
You retain all ownership rights to the content you upload or create through the Service (&ldquo;User Content&rdquo;). By uploading User Content, you grant us a limited, non-exclusive, royalty-free license to store, process, and display your User Content solely to provide the Service to you. You retain all ownership rights to the content you upload or create through the
Service (&ldquo;User Content&rdquo;). By uploading User Content, you grant us a
limited, non-exclusive, royalty-free license to store, process, and display your User
Content solely to provide the Service to you.
</p> </p>
<p className="mt-3"> <p className="mt-3">
You are solely responsible for your User Content and represent that you have all necessary rights to upload and share it. You agree not to upload content that: (a) infringes any third-party intellectual property rights; (b) is unlawful, defamatory, or harmful; (c) contains malware or malicious code; or (d) violates any applicable law or regulation. You are solely responsible for your User Content and represent that you have all
necessary rights to upload and share it. You agree not to upload content that: (a)
infringes any third-party intellectual property rights; (b) is unlawful, defamatory,
or harmful; (c) contains malware or malicious code; or (d) violates any applicable law
or regulation.
</p> </p>
<p className="mt-3"> <p className="mt-3">
We may remove or suspend access to User Content that violates these Terms at our sole discretion. We may remove or suspend access to User Content that violates these Terms at our sole
discretion.
</p> </p>
</section> </section>
@@ -93,101 +143,170 @@ export default function TermsOfServicePage() {
<p>You agree not to:</p> <p>You agree not to:</p>
<ul className="mt-3 list-disc pl-5 space-y-2"> <ul className="mt-3 list-disc pl-5 space-y-2">
<li>Use the Service in any manner that violates applicable laws or regulations.</li> <li>Use the Service in any manner that violates applicable laws or regulations.</li>
<li>Attempt to gain unauthorized access to any part of the Service or its related systems.</li> <li>
Attempt to gain unauthorized access to any part of the Service or its related
systems.
</li>
<li>Interfere with or disrupt the integrity or performance of the Service.</li> <li>Interfere with or disrupt the integrity or performance of the Service.</li>
<li>Reverse engineer, decompile, or attempt to extract the source code of the Service (except where permitted by applicable open-source licenses).</li> <li>
Reverse engineer, decompile, or attempt to extract the source code of the Service
(except where permitted by applicable open-source licenses).
</li>
<li>Use the Service to send spam or unsolicited communications.</li> <li>Use the Service to send spam or unsolicited communications.</li>
<li>Use the Service to collect or harvest any personally identifiable information without authorization.</li> <li>
<li>Resell or sublicense access to the Service without written authorization from us.</li> Use the Service to collect or harvest any personally identifiable information
without authorization.
</li>
<li>
Resell or sublicense access to the Service without written authorization from us.
</li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">8. Intellectual Property</h2> <h2 className="text-base font-semibold text-foreground mb-3">
8. Intellectual Property
</h2>
<p> <p>
Excluding your User Content, the Service and all content, features, and functionality thereof including but not limited to software, text, graphics, logos, and design are owned by IPEK TECH LLC or its licensors and are protected by applicable intellectual property laws. Excluding your User Content, the Service and all content, features, and functionality
thereof including but not limited to software, text, graphics, logos, and design
are owned by IPEK TECH LLC or its licensors and are protected by applicable
intellectual property laws.
</p> </p>
<p className="mt-3"> <p className="mt-3">
The core platform code is made available as open-source software; please refer to the applicable license in the GitHub repository for details on permitted use. The core platform code is made available as open-source software; please refer to the
applicable license in the GitHub repository for details on permitted use.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">9. Privacy</h2> <h2 className="text-base font-semibold text-foreground mb-3">9. Privacy</h2>
<p> <p>
Your use of the Service is also governed by our <Link href="/privacy" className="text-primary hover:underline">Privacy Policy</Link>, which is incorporated into these Terms by reference. By using the Service, you consent to the collection and use of your information as described therein. Your use of the Service is also governed by our{' '}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
, which is incorporated into these Terms by reference. By using the Service, you
consent to the collection and use of your information as described therein.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">10. Disclaimers</h2> <h2 className="text-base font-semibold text-foreground mb-3">10. Disclaimers</h2>
<p> <p>
THE SERVICE IS PROVIDED &ldquo;AS IS&rdquo; AND &ldquo;AS AVAILABLE&rdquo; WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS. THE SERVICE IS PROVIDED &ldquo;AS IS&rdquo; AND &ldquo;AS AVAILABLE&rdquo; WITHOUT
WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
NON-INFRINGEMENT. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED,
ERROR-FREE, OR FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">11. Limitation of Liability</h2> <h2 className="text-base font-semibold text-foreground mb-3">
11. Limitation of Liability
</h2>
<p> <p>
TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, IPEK TECH LLC AND ITS OFFICERS, DIRECTORS, EMPLOYEES, AGENTS, AND LICENSORS SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES INCLUDING LOST PROFITS, DATA LOSS, OR BUSINESS INTERRUPTION ARISING FROM YOUR USE OF OR INABILITY TO USE THE SERVICE, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, IPEK TECH LLC AND ITS OFFICERS,
DIRECTORS, EMPLOYEES, AGENTS, AND LICENSORS SHALL NOT BE LIABLE FOR ANY INDIRECT,
INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES INCLUDING LOST PROFITS, DATA
LOSS, OR BUSINESS INTERRUPTION ARISING FROM YOUR USE OF OR INABILITY TO USE THE
SERVICE, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
</p> </p>
<p className="mt-3"> <p className="mt-3">
OUR TOTAL CUMULATIVE LIABILITY TO YOU FOR ALL CLAIMS ARISING FROM OR RELATED TO THESE TERMS OR THE SERVICE WILL NOT EXCEED THE GREATER OF (A) THE AMOUNT YOU PAID TO US IN THE 12 MONTHS PRECEDING THE CLAIM OR (B) USD $50. OUR TOTAL CUMULATIVE LIABILITY TO YOU FOR ALL CLAIMS ARISING FROM OR RELATED TO THESE
TERMS OR THE SERVICE WILL NOT EXCEED THE GREATER OF (A) THE AMOUNT YOU PAID TO US IN
THE 12 MONTHS PRECEDING THE CLAIM OR (B) USD $50.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">12. Indemnification</h2> <h2 className="text-base font-semibold text-foreground mb-3">12. Indemnification</h2>
<p> <p>
You agree to indemnify, defend, and hold harmless IPEK TECH LLC and its officers, directors, employees, agents, and licensors from and against any claims, damages, losses, liabilities, costs, and expenses (including reasonable attorneys&apos; fees) arising from: (a) your use of the Service; (b) your User Content; (c) your violation of these Terms; or (d) your violation of any third-party rights. You agree to indemnify, defend, and hold harmless IPEK TECH LLC and its officers,
directors, employees, agents, and licensors from and against any claims, damages,
losses, liabilities, costs, and expenses (including reasonable attorneys&apos; fees)
arising from: (a) your use of the Service; (b) your User Content; (c) your violation
of these Terms; or (d) your violation of any third-party rights.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">13. Termination</h2> <h2 className="text-base font-semibold text-foreground mb-3">13. Termination</h2>
<p> <p>
We may suspend or terminate your access to the Service at any time, with or without cause and with or without notice, if we believe you have violated these Terms or for any other reason at our sole discretion. We may suspend or terminate your access to the Service at any time, with or without
cause and with or without notice, if we believe you have violated these Terms or for
any other reason at our sole discretion.
</p> </p>
<p className="mt-3"> <p className="mt-3">
You may cancel your account at any time through your billing settings or by contacting us at <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a>. Upon termination, your right to access the Service will immediately cease. Sections that by their nature should survive termination (including Sections 8, 10, 11, 12, 14, and 15) will survive. You may cancel your account at any time through your billing settings or by contacting
us at{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>
. Upon termination, your right to access the Service will immediately cease. Sections
that by their nature should survive termination (including Sections 8, 10, 11, 12, 14,
and 15) will survive.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">14. Governing Law and Dispute Resolution</h2> <h2 className="text-base font-semibold text-foreground mb-3">
14. Governing Law and Dispute Resolution
</h2>
<p> <p>
These Terms are governed by the laws of the State of Wyoming, United States, without regard to its conflict-of-law provisions. Any disputes arising from or relating to these Terms or the Service shall be resolved exclusively in the state or federal courts located in Wyoming, and you consent to personal jurisdiction in such courts. These Terms are governed by the laws of the State of Wyoming, United States, without
regard to its conflict-of-law provisions. Any disputes arising from or relating to
these Terms or the Service shall be resolved exclusively in the state or federal
courts located in Wyoming, and you consent to personal jurisdiction in such courts.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">15. Changes to Terms</h2> <h2 className="text-base font-semibold text-foreground mb-3">15. Changes to Terms</h2>
<p> <p>
We reserve the right to modify these Terms at any time. We will notify you of material changes by posting the updated Terms on this page and updating the &ldquo;Last updated&rdquo; date. Your continued use of the Service after any changes constitutes your acceptance of the new Terms. We reserve the right to modify these Terms at any time. We will notify you of material
changes by posting the updated Terms on this page and updating the &ldquo;Last
updated&rdquo; date. Your continued use of the Service after any changes constitutes
your acceptance of the new Terms.
</p> </p>
</section> </section>
<section> <section>
<h2 className="text-base font-semibold text-foreground mb-3">16. Contact</h2> <h2 className="text-base font-semibold text-foreground mb-3">16. Contact</h2>
<p> <p>If you have any questions about these Terms, please contact us:</p>
If you have any questions about these Terms, please contact us:
</p>
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1"> <div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
<p className="font-medium text-foreground">IPEK TECH LLC</p> <p className="font-medium text-foreground">IPEK TECH LLC</p>
<p>Wyoming, United States</p> <p>Wyoming, United States</p>
<p>Email: <a href="mailto:[email protected]" className="text-primary hover:underline">info@open-frame.net</a></p> <p>
Email:{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
info@open-frame.net
</a>
</p>
</div> </div>
</section> </section>
</div> </div>
</main> </main>
<footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8"> <footer className="border-t border-border px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[900px] items-center justify-between"> <div className="mx-auto flex max-w-[900px] items-center justify-between">
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span> <span className="font-mono text-xs text-muted-foreground">
© 2026 IPEK TECH LLC. All rights reserved.
</span>
<div className="flex gap-4"> <div className="flex gap-4">
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Privacy Policy</Link> <Link
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground transition-colors">Refund Policy</Link> href="/privacy"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
href="/refund"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Refund Policy
</Link>
</div> </div>
</div> </div>
</footer> </footer>
+7 -11
View File
@@ -1,8 +1,8 @@
"use client"; 'use client';
import { useEffect } from "react"; import { useEffect } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { AlertTriangle, Film } from "lucide-react"; import { AlertTriangle, Film } from 'lucide-react';
export default function WatchError({ export default function WatchError({
error, error,
@@ -12,7 +12,7 @@ export default function WatchError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
console.error("Watch page error:", error); console.error('Watch page error:', error);
}, [error]); }, [error]);
return ( return (
@@ -26,17 +26,13 @@ export default function WatchError({
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
We couldn&apos;t load this video. It may have been removed or the link might be incorrect. We couldn&apos;t load this video. It may have been removed or the link might be incorrect.
</p> </p>
{error.digest && ( {error.digest && <p className="text-muted-foreground text-xs">Error ID: {error.digest}</p>}
<p className="text-muted-foreground text-xs">
Error ID: {error.digest}
</p>
)}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={reset} variant="default"> <Button onClick={reset} variant="default">
Try again Try again
</Button> </Button>
<Button onClick={() => window.location.href = "/"} variant="outline"> <Button onClick={() => (window.location.href = '/')} variant="outline">
Go home Go home
</Button> </Button>
</div> </div>
+4 -4
View File
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from "@/components/ui/separator" import { Separator } from '@/components/ui/separator';
function CommentSkeleton() { function CommentSkeleton() {
return ( return (
@@ -14,7 +14,7 @@ function CommentSkeleton() {
<Skeleton className="h-4 w-full mb-1" /> <Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-4 w-2/3" />
</div> </div>
) );
} }
export default function WatchLoading() { export default function WatchLoading() {
@@ -77,5 +77,5 @@ export default function WatchLoading() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
+5 -4
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from 'next/link';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Film, XCircle } from "lucide-react"; import { Film, XCircle } from 'lucide-react';
export default function WatchNotFound() { export default function WatchNotFound() {
return ( return (
@@ -12,7 +12,8 @@ export default function WatchNotFound() {
</div> </div>
<h1 className="text-2xl font-bold">Video Not Found</h1> <h1 className="text-2xl font-bold">Video Not Found</h1>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
The video you&apos;re looking for doesn&apos;t exist, has been removed, or the link may be expired. The video you&apos;re looking for doesn&apos;t exist, has been removed, or the link may be
expired.
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
+4 -1
View File
@@ -83,7 +83,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const tokenForAttempt = shareTokenFromBody || pendingToken; const tokenForAttempt = shareTokenFromBody || pendingToken;
if (!tokenForAttempt) { if (!tokenForAttempt) {
return NextResponse.json({ error: 'Share session expired. Open the share link again.' }, { status: 401 }); return NextResponse.json(
{ error: 'Share session expired. Open the share link again.' },
{ status: 401 }
);
} }
// Additional throttle bound to token+IP to reduce password guessing against one link. // Additional throttle bound to token+IP to reduce password guessing against one link.
+222 -28
View File
@@ -16,7 +16,7 @@
"gsap": "^3.14.2", "gsap": "^3.14.2",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "^16.2.3", "next": "16.2.3",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
@@ -34,6 +34,8 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/node": "^20", "@types/node": "^20",
@@ -43,6 +45,10 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.5",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"shadcn": "^3.8.3", "shadcn": "^3.8.3",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
@@ -206,6 +212,40 @@
"@chevrotain/utils": ["@chevrotain/[email protected]", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], "@chevrotain/utils": ["@chevrotain/[email protected]", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="],
"@commitlint/cli": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/format": "^19.8.1", "@commitlint/lint": "^19.8.1", "@commitlint/load": "^19.8.1", "@commitlint/read": "^19.8.1", "@commitlint/types": "^19.8.1", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA=="],
"@commitlint/config-conventional": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/types": "^19.8.1", "conventional-changelog-conventionalcommits": "^7.0.2" } }, "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ=="],
"@commitlint/config-validator": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/types": "^19.8.1", "ajv": "^8.11.0" } }, "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ=="],
"@commitlint/ensure": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/types": "^19.8.1", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", "lodash.startcase": "^4.4.0", "lodash.upperfirst": "^4.3.1" } }, "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw=="],
"@commitlint/execute-rule": ["@commitlint/[email protected]", "", {}, "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA=="],
"@commitlint/format": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/types": "^19.8.1", "chalk": "^5.3.0" } }, "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw=="],
"@commitlint/is-ignored": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/types": "^19.8.1", "semver": "^7.6.0" } }, "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg=="],
"@commitlint/lint": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/is-ignored": "^19.8.1", "@commitlint/parse": "^19.8.1", "@commitlint/rules": "^19.8.1", "@commitlint/types": "^19.8.1" } }, "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw=="],
"@commitlint/load": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/config-validator": "^19.8.1", "@commitlint/execute-rule": "^19.8.1", "@commitlint/resolve-extends": "^19.8.1", "@commitlint/types": "^19.8.1", "chalk": "^5.3.0", "cosmiconfig": "^9.0.0", "cosmiconfig-typescript-loader": "^6.1.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "lodash.uniq": "^4.5.0" } }, "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A=="],
"@commitlint/message": ["@commitlint/[email protected]", "", {}, "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg=="],
"@commitlint/parse": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/types": "^19.8.1", "conventional-changelog-angular": "^7.0.0", "conventional-commits-parser": "^5.0.0" } }, "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw=="],
"@commitlint/read": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/top-level": "^19.8.1", "@commitlint/types": "^19.8.1", "git-raw-commits": "^4.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" } }, "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ=="],
"@commitlint/resolve-extends": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/config-validator": "^19.8.1", "@commitlint/types": "^19.8.1", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", "resolve-from": "^5.0.0" } }, "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg=="],
"@commitlint/rules": ["@commitlint/[email protected]", "", { "dependencies": { "@commitlint/ensure": "^19.8.1", "@commitlint/message": "^19.8.1", "@commitlint/to-lines": "^19.8.1", "@commitlint/types": "^19.8.1" } }, "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw=="],
"@commitlint/to-lines": ["@commitlint/[email protected]", "", {}, "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg=="],
"@commitlint/top-level": ["@commitlint/[email protected]", "", { "dependencies": { "find-up": "^7.0.0" } }, "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw=="],
"@commitlint/types": ["@commitlint/[email protected]", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw=="],
"@dotenvx/dotenvx": ["@dotenvx/[email protected]", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], "@dotenvx/dotenvx": ["@dotenvx/[email protected]", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="],
"@ecies/ciphers": ["@ecies/[email protected]", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], "@ecies/ciphers": ["@ecies/[email protected]", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
@@ -674,6 +714,8 @@
"@types/bcryptjs": ["@types/[email protected]", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], "@types/bcryptjs": ["@types/[email protected]", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/conventional-commits-parser": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -752,6 +794,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"JSONStream": ["[email protected]", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="],
"accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -764,6 +808,8 @@
"ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-escapes": ["[email protected]", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -778,6 +824,8 @@
"array-buffer-byte-length": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-buffer-byte-length": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"array-ify": ["[email protected]", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="],
"array-includes": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], "array-includes": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
"array.prototype.findlast": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], "array.prototype.findlast": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
@@ -854,6 +902,8 @@
"cli-spinners": ["[email protected]", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], "cli-spinners": ["[email protected]", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["[email protected]", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"cli-width": ["[email protected]", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "cli-width": ["[email protected]", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -868,9 +918,13 @@
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colorette": ["[email protected]", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"combine-errors": ["[email protected]", "", { "dependencies": { "custom-error-instance": "2.1.1", "lodash.uniqby": "4.5.0" } }, "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q=="], "combine-errors": ["[email protected]", "", { "dependencies": { "custom-error-instance": "2.1.1", "lodash.uniqby": "4.5.0" } }, "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"compare-func": ["[email protected]", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -882,6 +936,12 @@
"content-type": ["[email protected]", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "content-type": ["[email protected]", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"conventional-changelog-angular": ["[email protected]", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ=="],
"conventional-changelog-conventionalcommits": ["[email protected]", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="],
"conventional-commits-parser": ["[email protected]", "", { "dependencies": { "JSONStream": "^1.3.5", "is-text-path": "^2.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "conventional-commits-parser": "cli.mjs" } }, "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["[email protected]", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie": ["[email protected]", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -892,6 +952,8 @@
"cosmiconfig": ["[email protected]", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cosmiconfig": ["[email protected]", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cosmiconfig-typescript-loader": ["[email protected]", "", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -902,6 +964,8 @@
"damerau-levenshtein": ["[email protected]", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "damerau-levenshtein": ["[email protected]", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"dargs": ["[email protected]", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="],
"data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-view-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -948,6 +1012,8 @@
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"dotenv": ["[email protected]", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dotenv": ["[email protected]", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -970,6 +1036,8 @@
"env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"environment": ["[email protected]", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"error-ex": ["[email protected]", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "error-ex": ["[email protected]", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-abstract": ["[email protected]", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], "es-abstract": ["[email protected]", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@@ -998,6 +1066,8 @@
"eslint-config-next": ["[email protected]", "", { "dependencies": { "@next/eslint-plugin-next": "16.1.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA=="], "eslint-config-next": ["[email protected]", "", { "dependencies": { "@next/eslint-plugin-next": "16.1.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA=="],
"eslint-config-prettier": ["[email protected]", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
"eslint-import-resolver-node": ["[email protected]", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], "eslint-import-resolver-node": ["[email protected]", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
"eslint-import-resolver-typescript": ["[email protected]", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], "eslint-import-resolver-typescript": ["[email protected]", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
@@ -1030,11 +1100,13 @@
"etag": ["[email protected]", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "etag": ["[email protected]", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["[email protected]", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource": ["[email protected]", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource": ["[email protected]", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["[email protected]", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "eventsource-parser": ["[email protected]", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -1120,7 +1192,7 @@
"get-proto": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"get-symbol-description": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
@@ -1128,8 +1200,12 @@
"giget": ["[email protected]", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["[email protected]", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"git-raw-commits": ["[email protected]", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="],
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global-directory": ["[email protected]", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="],
"globals": ["[email protected]", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], "globals": ["[email protected]", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
"globalthis": ["[email protected]", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globalthis": ["[email protected]", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -1176,7 +1252,9 @@
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"husky": ["[email protected]", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1184,10 +1262,14 @@
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["[email protected]", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"imurmurhash": ["[email protected]", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "imurmurhash": ["[email protected]", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["[email protected]", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
"internal-slot": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internal-slot": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"ip-address": ["[email protected]", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], "ip-address": ["[email protected]", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
@@ -1264,6 +1346,8 @@
"is-symbol": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], "is-symbol": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
"is-text-path": ["[email protected]", "", { "dependencies": { "text-extensions": "^2.0.0" } }, "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw=="],
"is-typed-array": ["[email protected]", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], "is-typed-array": ["[email protected]", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"is-unicode-supported": ["[email protected]", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-unicode-supported": ["[email protected]", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
@@ -1308,6 +1392,8 @@
"jsonfile": ["[email protected]", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "jsonfile": ["[email protected]", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jsonparse": ["[email protected]", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
"jsx-ast-utils": ["[email protected]", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["[email protected]", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -1344,10 +1430,14 @@
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["[email protected]", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "debug": "^4.4.0", "execa": "^8.0.1", "lilconfig": "^3.1.3", "listr2": "^8.2.5", "micromatch": "^4.0.8", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.7.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w=="],
"listr2": ["[email protected]", "", { "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["[email protected]", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash": ["[email protected]", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
@@ -1364,14 +1454,32 @@
"lodash._stringtopath": ["[email protected]", "", { "dependencies": { "lodash._basetostring": "~4.12.0" } }, "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ=="], "lodash._stringtopath": ["[email protected]", "", { "dependencies": { "lodash._basetostring": "~4.12.0" } }, "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ=="],
"lodash.camelcase": ["[email protected]", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.isplainobject": ["[email protected]", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.kebabcase": ["[email protected]", "", {}, "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.mergewith": ["[email protected]", "", {}, "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="],
"lodash.snakecase": ["[email protected]", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"lodash.startcase": ["[email protected]", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
"lodash.throttle": ["[email protected]", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], "lodash.throttle": ["[email protected]", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
"lodash.uniq": ["[email protected]", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
"lodash.uniqby": ["[email protected]", "", { "dependencies": { "lodash._baseiteratee": "~4.7.0", "lodash._baseuniq": "~4.6.0" } }, "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ=="], "lodash.uniqby": ["[email protected]", "", { "dependencies": { "lodash._baseiteratee": "~4.7.0", "lodash._baseuniq": "~4.6.0" } }, "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ=="],
"lodash.upperfirst": ["[email protected]", "", {}, "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg=="],
"log-symbols": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "log-symbols": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"log-update": ["[email protected]", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"long": ["[email protected]", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "long": ["[email protected]", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -1388,6 +1496,8 @@
"media-typer": ["[email protected]", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "media-typer": ["[email protected]", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"meow": ["[email protected]", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="],
"merge-descriptors": ["[email protected]", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-descriptors": ["[email protected]", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["[email protected]", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge-stream": ["[email protected]", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
@@ -1400,7 +1510,7 @@
"mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -1442,7 +1552,7 @@
"nodemailer": ["[email protected]", "", {}, "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w=="], "nodemailer": ["[email protected]", "", {}, "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"nypm": ["[email protected]", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "nypm": ["[email protected]", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
@@ -1472,7 +1582,7 @@
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -1532,7 +1642,9 @@
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pidtree": ["[email protected]", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pkce-challenge": ["[email protected]", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["[email protected]", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
@@ -1562,6 +1674,8 @@
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"pretty-ms": ["[email protected]", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "pretty-ms": ["[email protected]", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"prisma": ["[email protected]", "", { "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", "@prisma/engines": "7.3.0", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w=="], "prisma": ["[email protected]", "", { "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", "@prisma/engines": "7.3.0", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w=="],
@@ -1626,7 +1740,7 @@
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -1638,6 +1752,8 @@
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["[email protected]", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"router": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "router": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["[email protected]", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-applescript": ["[email protected]", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
@@ -1690,6 +1806,8 @@
"sisteransi": ["[email protected]", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sisteransi": ["[email protected]", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slice-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"sonner": ["[email protected]", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "sonner": ["[email protected]", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1712,7 +1830,9 @@
"strict-event-emitter": ["[email protected]", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], "strict-event-emitter": ["[email protected]", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"string-width": ["string-[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string.prototype.includes": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], "string.prototype.includes": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
@@ -1732,7 +1852,7 @@
"strip-bom": ["[email protected]", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-bom": ["[email protected]", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@@ -1754,6 +1874,10 @@
"tapable": ["[email protected]", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["[email protected]", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"text-extensions": ["[email protected]", "", {}, "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g=="],
"through": ["[email protected]", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="],
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["[email protected]", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyexec": ["[email protected]", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -1848,7 +1972,7 @@
"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
@@ -1860,6 +1984,8 @@
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yargs": ["[email protected]", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs": ["[email protected]", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["[email protected]", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yargs-parser": ["[email protected]", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
@@ -1902,20 +2028,36 @@
"@babel/helper-create-class-features-plugin/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@commitlint/config-validator/ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"@commitlint/format/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@commitlint/load/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@commitlint/top-level/find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="],
"@commitlint/types/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@dotenvx/dotenvx/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@dotenvx/dotenvx/which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@dotenvx/dotenvx/which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["[email protected]", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["[email protected]", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@inquirer/core/wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@modelcontextprotocol/sdk/ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "@modelcontextprotocol/sdk/ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"@modelcontextprotocol/sdk/zod": ["[email protected]", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@modelcontextprotocol/sdk/zod": ["[email protected]", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@mrleebo/prisma-ast/lilconfig": ["[email protected]", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"@next/eslint-plugin-next/fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "@next/eslint-plugin-next/fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
"@prisma/dev/hono": ["[email protected]", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], "@prisma/dev/hono": ["[email protected]", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
@@ -1948,12 +2090,14 @@
"c12/dotenv": ["[email protected]", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "c12/dotenv": ["[email protected]", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "cliui/wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"dot-prop/is-obj": ["[email protected]", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"eslint-import-resolver-node/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1970,19 +2114,23 @@
"eslint-plugin-react-hooks/zod": ["[email protected]", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "eslint-plugin-react-hooks/zod": ["[email protected]", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"express/cookie": ["[email protected]", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/cookie": ["[email protected]", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"import-fresh/resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"lint-staged/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"log-symbols/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "log-symbols/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"log-symbols/is-unicode-supported": ["[email protected]", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "log-symbols/is-unicode-supported": ["[email protected]", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "log-update/slice-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -1994,6 +2142,8 @@
"ora/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "ora/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"ora/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"pg-types/postgres-array": ["[email protected]", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "pg-types/postgres-array": ["[email protected]", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"prompts/kleur": ["[email protected]", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "prompts/kleur": ["[email protected]", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -2004,13 +2154,23 @@
"router/path-to-regexp": ["[email protected]", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "router/path-to-regexp": ["[email protected]", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"string-width/emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "shadcn/commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"wrap-ansi/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "shadcn/execa": ["[email protected]", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"wrap-ansi/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "slice-ansi/ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"yargs/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"string-width/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"wrap-ansi/ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
@@ -2030,18 +2190,30 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@commitlint/config-validator/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@commitlint/top-level/find-up/locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="],
"@commitlint/top-level/find-up/path-exists": ["[email protected]", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"@commitlint/top-level/find-up/unicorn-magic": ["[email protected]", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="],
"@dotenvx/dotenvx/execa/get-stream": ["[email protected]", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/get-stream": ["[email protected]", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@dotenvx/dotenvx/execa/human-signals": ["[email protected]", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "@dotenvx/dotenvx/execa/human-signals": ["[email protected]", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"@dotenvx/dotenvx/execa/npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "@dotenvx/dotenvx/execa/npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"@dotenvx/dotenvx/execa/onetime": ["[email protected]", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"@dotenvx/dotenvx/execa/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "@dotenvx/dotenvx/execa/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"@dotenvx/dotenvx/execa/strip-final-newline": ["[email protected]", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], "@dotenvx/dotenvx/execa/strip-final-newline": ["[email protected]", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@dotenvx/dotenvx/which/isexe": ["[email protected]", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], "@dotenvx/dotenvx/which/isexe": ["[email protected]", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"@inquirer/core/wrap-ansi/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@next/eslint-plugin-next/fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@next/eslint-plugin-next/fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -2050,19 +2222,31 @@
"ajv-formats/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ajv-formats/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"cliui/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cliui/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"eslint-plugin-import/tsconfig-paths/json5": ["[email protected]", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "eslint-plugin-import/tsconfig-paths/json5": ["[email protected]", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"wrap-ansi/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "log-update/slice-ansi/ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "log-update/slice-ansi/is-fullwidth-code-point": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"yargs/string-width/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "shadcn/execa/get-stream": ["[email protected]", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"shadcn/execa/human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"shadcn/execa/is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"shadcn/execa/npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"shadcn/execa/strip-final-newline": ["[email protected]", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
@@ -2070,6 +2254,16 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"@dotenvx/dotenvx/execa/onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"shadcn/execa/npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
"@commitlint/top-level/find-up/locate-path/p-locate/p-limit/yocto-queue": ["[email protected]", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
} }
} }
+26 -122
View File
@@ -1,132 +1,36 @@
// commitlint.config.js
// Conventional Commits — https://www.conventionalcommits.org/en/v1.0.0/
//
// FORMAT:
// <type>(<scope>): <description>
//
// [optional body]
//
// [optional footer(s)]
//
// EXAMPLES:
// feat(auth): add OAuth2 login with GitHub
// fix(video): resolve HLS buffering on mobile Safari
// docs: update self-hosting guide in README
// chore(deps): bump next from 16.1 to 16.2
// refactor(api): extract pagination helper into lib/utils
// perf(db): add index on Project.createdAt for dashboard query
// ci: configure GitHub Actions matrix for Node 20 and 22
// revert: revert "feat(billing): add yearly plan toggle"
/** @type {import('@commitlint/types').UserConfig} */ /** @type {import('@commitlint/types').UserConfig} */
const config = { const config = {
extends: ["@commitlint/config-conventional"], extends: ['@commitlint/config-conventional'],
// ── Rules ──────────────────────────────────────────────────────────────────
// Each rule is [severity, applicable, value]
// severity 0 = off, 1 = warn, 2 = error
// applicable 'always' | 'never'
rules: { rules: {
// ── Type ──────────────────────────────────────────────────────────────── 'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
// Must be lowercase (e.g. "feat" not "Feat") 'type-enum': [
"type-case": [2, "always", "lower-case"],
// Must not be empty
"type-empty": [2, "never"],
// Allowed commit types
"type-enum": [
2, 2,
"always", 'always',
[ [
"feat", // ✨ New feature or user-facing capability 'feat',
"fix", // 🐛 Bug fix 'fix',
"docs", // 📝 Documentation only (README, comments, JSDoc) 'docs',
"style", // 💄 Formatting, whitespace — no logic change 'style',
"refactor", // ♻️ Code restructure — no new feature, no bug fix 'refactor',
"perf", // ⚡️ Performance improvement 'perf',
"test", // 🧪 Adding or correcting tests 'test',
"build", // 🏗️ Build system, bundler, or external dependencies 'build',
"ci", // 👷 CI/CD pipeline changes (GitHub Actions, Docker, etc.) 'ci',
"chore", // 🔧 Maintenance tasks not fitting other types 'chore',
"revert", // ⏪ Reverts a previous commit 'revert',
"security", // 🔒 Security fix or hardening (not in base config)
"i18n", // 🌐 Internationalisation / translation (not in base)
"dx", // 🛠️ Developer experience (tooling, scripts, configs)
], ],
], ],
'scope-case': [2, 'always', 'lower-case'],
// ── Scope ─────────────────────────────────────────────────────────────── 'subject-empty': [2, 'never'],
'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']],
// Scope must be lowercase when provided (e.g. "auth" not "Auth") 'subject-full-stop': [2, 'never', '.'],
"scope-case": [2, "always", "lower-case"], 'header-max-length': [2, 'always', 100],
'subject-min-length': [2, 'always', 10],
// Scope is optional — no rule enforced for "scope-empty" 'body-max-line-length': [2, 'always', 120],
// Suggested scopes (not enforced): 'body-leading-blank': [2, 'always'],
// auth, api, db, video, billing, admin, ci, deps, ui, email, 'footer-leading-blank': [1, 'always'],
// invitations, workspace, project, storage, notifications, onboarding 'footer-max-line-length': [2, 'always', 120],
// ── Subject (short description) ─────────────────────────────────────────
// Must not be empty
"subject-empty": [2, "never"],
// Must NOT start with an uppercase letter
// Good: "add retry logic for upload"
// Bad: "Add retry logic for upload"
"subject-case": [2, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"]],
// Must NOT end with a period
"subject-full-stop": [2, "never", "."],
// Max 100 chars for the subject line (type + scope + description)
"header-max-length": [2, "always", 100],
// Min 10 chars to prevent meaningless one-word subjects
"subject-min-length": [2, "always", 10],
// ── Body ────────────────────────────────────────────────────────────────
// Body lines should be wrapped at 120 chars
"body-max-line-length": [2, "always", 120],
// Blank line required between subject and body
"body-leading-blank": [2, "always"],
// ── Footer ──────────────────────────────────────────────────────────────
// Blank line required between body and footer
"footer-leading-blank": [1, "always"],
// Footer lines should be wrapped at 120 chars
"footer-max-line-length": [2, "always", 120],
},
// ── Prompt (interactive `bun run commit`) ──────────────────────────────────
// Shown when using @commitlint/prompt-cli or czg
prompt: {
questions: {
type: {
description: "Select the type of change you are committing",
enum: {
feat: { description: "A new feature", title: "Features", emoji: "✨" },
fix: { description: "A bug fix", title: "Bug Fixes", emoji: "🐛" },
docs: { description: "Documentation only changes", title: "Documentation", emoji: "📝" },
style: { description: "Formatting, missing semicolons, etc.", title: "Styles", emoji: "💄" },
refactor: { description: "A code change without feature or fix", title: "Refactors", emoji: "♻️" },
perf: { description: "A performance improvement", title: "Performance", emoji: "⚡️" },
test: { description: "Adding or correcting tests", title: "Tests", emoji: "🧪" },
build: { description: "Build system or dependency changes", title: "Builds", emoji: "🏗️" },
ci: { description: "CI/CD configuration changes", title: "CI", emoji: "👷" },
chore: { description: "Other changes (tooling, maintenance)", title: "Chores", emoji: "🔧" },
revert: { description: "Reverts a previous commit", title: "Reverts", emoji: "⏪" },
security: { description: "A security fix or hardening", title: "Security", emoji: "🔒" },
i18n: { description: "Internationalisation / translation", title: "i18n", emoji: "🌐" },
dx: { description: "Developer experience improvements", title: "DX", emoji: "🛠️" },
},
},
},
}, },
}; };
+314 -87
View File
@@ -26,10 +26,9 @@ import {
CheckCircle, CheckCircle,
Upload, Upload,
Share2, Share2,
Check Check,
} from 'lucide-react'; } from 'lucide-react';
interface LandingPageProps { interface LandingPageProps {
isLoggedIn: boolean; isLoggedIn: boolean;
} }
@@ -55,7 +54,8 @@ const coreWorkflowFeatures = [
}, },
{ {
title: 'Approval Workflow', title: 'Approval Workflow',
description: 'Assign specific team members or clients to review and sign off on a cut. Get an exact \"Approved\" status.', description:
'Assign specific team members or clients to review and sign off on a cut. Get an exact \"Approved\" status.',
icon: CheckCircle, icon: CheckCircle,
}, },
]; ];
@@ -73,7 +73,8 @@ const workflowAcceleratorFeatures = [
}, },
{ {
title: 'Real-time Webhooks', title: 'Real-time Webhooks',
description: 'Get instant Telegram alerts the second a comment is dropped. More integrations coming soon.', description:
'Get instant Telegram alerts the second a comment is dropped. More integrations coming soon.',
icon: BellRing, icon: BellRing,
}, },
{ {
@@ -121,7 +122,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
const updateNavbar = () => { const updateNavbar = () => {
const hasScrolled = window.scrollY > 20; const hasScrolled = window.scrollY > 20;
gsap.to(nav, { gsap.to(nav, {
backgroundColor: hasScrolled ? 'color-mix(in oklab, var(--background) 85%, transparent)' : 'transparent', backgroundColor: hasScrolled
? 'color-mix(in oklab, var(--background) 85%, transparent)'
: 'transparent',
backdropFilter: hasScrolled ? 'blur(16px)' : 'blur(0px)', backdropFilter: hasScrolled ? 'blur(16px)' : 'blur(0px)',
borderBottomColor: hasScrolled ? 'var(--border)' : 'transparent', borderBottomColor: hasScrolled ? 'var(--border)' : 'transparent',
duration: 0.3, duration: 0.3,
@@ -141,27 +144,50 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
}, []); }, []);
return ( return (
<div ref={rootRef} className="min-h-screen overflow-x-hidden bg-background text-foreground font-sans selection:bg-primary/20"> <div
ref={rootRef}
className="min-h-screen overflow-x-hidden bg-background text-foreground font-sans selection:bg-primary/20"
>
{/* Header */} {/* Header */}
<header ref={navbarRef} className="fixed inset-x-0 top-0 z-50 border-b border-transparent bg-transparent transition-colors duration-300"> <header
ref={navbarRef}
className="fixed inset-x-0 top-0 z-50 border-b border-transparent bg-transparent transition-colors duration-300"
>
<div className="mx-auto flex h-14 w-full max-w-[1200px] items-center justify-between px-4 sm:h-16 sm:px-6 lg:px-10"> <div className="mx-auto flex h-14 w-full max-w-[1200px] items-center justify-between px-4 sm:h-16 sm:px-6 lg:px-10">
<Link href="/" className="group relative isolate inline-flex items-center gap-2 overflow-hidden border border-border bg-background px-3 py-2"> <Link
href="/"
className="group relative isolate inline-flex items-center gap-2 overflow-hidden border border-border bg-background px-3 py-2"
>
<span className="pointer-events-none absolute inset-0 -translate-x-[101%] bg-primary/10 transition-transform duration-300 group-hover:translate-x-0" /> <span className="pointer-events-none absolute inset-0 -translate-x-[101%] bg-primary/10 transition-transform duration-300 group-hover:translate-x-0" />
<Video className="relative z-10 h-4 w-4 text-primary" /> <Video className="relative z-10 h-4 w-4 text-primary" />
<span className="relative z-10 text-xs font-semibold tracking-[0.12em]">OPENFRAME</span> <span className="relative z-10 text-xs font-semibold tracking-[0.12em]">OPENFRAME</span>
</Link> </Link>
<nav className="hidden items-center gap-6 text-[11px] font-medium uppercase tracking-[0.14em] md:flex"> <nav className="hidden items-center gap-6 text-[11px] font-medium uppercase tracking-[0.14em] md:flex">
<Link className="text-muted-foreground transition-colors hover:text-foreground" href="#features"> <Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="#features"
>
Features Features
</Link> </Link>
<Link className="text-muted-foreground transition-colors hover:text-foreground" href="#pricing"> <Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="#pricing"
>
Pricing Pricing
</Link> </Link>
<a className="text-muted-foreground transition-colors hover:text-foreground" href="https://github.com/yusufipk/OpenFrame" target="_blank" rel="noreferrer"> <a
className="text-muted-foreground transition-colors hover:text-foreground"
href="https://github.com/yusufipk/OpenFrame"
target="_blank"
rel="noreferrer"
>
GitHub GitHub
</a> </a>
<a className="text-muted-foreground transition-colors hover:text-foreground" href="mailto:[email protected]"> <a
className="text-muted-foreground transition-colors hover:text-foreground"
href="mailto:[email protected]"
>
Contact Contact
</a> </a>
</nav> </nav>
@@ -177,7 +203,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</Link> </Link>
) : ( ) : (
<> <>
<Link href="/login" className="text-xs font-medium text-muted-foreground hover:text-foreground hidden sm:block mr-4"> <Link
href="/login"
className="text-xs font-medium text-muted-foreground hover:text-foreground hidden sm:block mr-4"
>
Log in Log in
</Link> </Link>
<Link href="/register" className={controlButtonClass}> <Link href="/register" className={controlButtonClass}>
@@ -196,7 +225,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" /> <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
<div className="relative z-10 mx-auto max-w-[1000px] space-y-8"> <div className="relative z-10 mx-auto max-w-[1000px] space-y-8">
<div data-hero-copy className="inline-flex items-center gap-2 border border-border/50 bg-secondary/30 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-md"> <div
data-hero-copy
className="inline-flex items-center gap-2 border border-border/50 bg-secondary/30 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-md"
>
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary"></span> <span className="relative inline-flex h-2 w-2 rounded-full bg-primary"></span>
@@ -204,16 +236,26 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<span className="font-mono tracking-wide uppercase">Open Source Video Review</span> <span className="font-mono tracking-wide uppercase">Open Source Video Review</span>
</div> </div>
<h1 data-hero-copy className="text-4xl font-semibold leading-[0.95] tracking-[-0.03em] sm:text-5xl md:text-6xl lg:text-7xl"> <h1
data-hero-copy
className="text-4xl font-semibold leading-[0.95] tracking-[-0.03em] sm:text-5xl md:text-6xl lg:text-7xl"
>
Cut Video Approval Time in Half. <br className="hidden md:block" /> Cut Video Approval Time in Half. <br className="hidden md:block" />
<span className="text-muted-foreground">Stop Chasing Timecodes.</span> <span className="text-muted-foreground">Stop Chasing Timecodes.</span>
</h1> </h1>
<p data-hero-copy className="mx-auto max-w-2xl text-base text-muted-foreground md:text-xl"> <p
OpenFrame puts comments, voice notes, and annotations on a single timeline so clients say &quot;yes&quot; faster and your team stops guessing. data-hero-copy
className="mx-auto max-w-2xl text-base text-muted-foreground md:text-xl"
>
OpenFrame puts comments, voice notes, and annotations on a single timeline so clients
say &quot;yes&quot; faster and your team stops guessing.
</p> </p>
<div data-hero-copy className="mx-auto flex max-w-md flex-col items-center justify-center gap-2"> <div
data-hero-copy
className="mx-auto flex max-w-md flex-col items-center justify-center gap-2"
>
<Link <Link
href={hostedCtaHref} href={hostedCtaHref}
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]" className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
@@ -222,7 +264,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" /> <MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link> </Link>
<a href="#pricing" className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"> <a
href="#pricing"
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Prefer self-hosting? <ArrowRight className="ml-1 inline h-3.5 w-3.5" /> Prefer self-hosting? <ArrowRight className="ml-1 inline h-3.5 w-3.5" />
</a> </a>
</div> </div>
@@ -252,7 +297,6 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
@@ -261,7 +305,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-y border-border bg-card/10 px-4 py-16 sm:px-6 lg:px-8"> <section className="border-y border-border bg-card/10 px-4 py-16 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-[1200px]"> <div className="mx-auto w-full max-w-[1200px]">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Feedback chaos looks like this:</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Feedback chaos looks like this:
</h2>
<ul className="mt-8 space-y-4 text-base text-muted-foreground md:text-lg"> <ul className="mt-8 space-y-4 text-base text-muted-foreground md:text-lg">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<XCircle className="mt-1 h-5 w-5 shrink-0 text-red-500/80" /> <XCircle className="mt-1 h-5 w-5 shrink-0 text-red-500/80" />
@@ -293,7 +339,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border bg-background px-4 py-20 sm:px-6 lg:px-8"> <section className="border-b border-border bg-background px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-[1200px]"> <div className="mx-auto w-full max-w-[1200px]">
<div className="mb-16 text-center"> <div className="mb-16 text-center">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">From upload to approval in one flow.</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
From upload to approval in one flow.
</h2>
</div> </div>
<div className="grid gap-4 md:grid-cols-4 relative"> <div className="grid gap-4 md:grid-cols-4 relative">
@@ -301,7 +349,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{ label: 'Upload a cut', icon: Upload }, { label: 'Upload a cut', icon: Upload },
{ label: 'Share a review link', icon: Share2 }, { label: 'Share a review link', icon: Share2 },
{ label: 'Get timestamped feedback', icon: MessageSquare }, { label: 'Get timestamped feedback', icon: MessageSquare },
{ label: 'Mark approved and move on', icon: Check } { label: 'Mark approved and move on', icon: Check },
].map((step, idx, arr) => ( ].map((step, idx, arr) => (
<div key={step.label} className="group relative flex flex-col items-center"> <div key={step.label} className="group relative flex flex-col items-center">
<div className="flex flex-col items-center justify-center w-full min-h-[100px] md:aspect-[4/3] border border-border bg-card/20 px-4 py-6 md:p-6 text-center relative z-10 transition-all duration-300 group-hover:border-primary/40 group-hover:bg-card/40"> <div className="flex flex-col items-center justify-center w-full min-h-[100px] md:aspect-[4/3] border border-border bg-card/20 px-4 py-6 md:p-6 text-center relative z-10 transition-all duration-300 group-hover:border-primary/40 group-hover:bg-card/40">
@@ -334,22 +382,31 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{/* 4) FEATURES */} {/* 4) FEATURES */}
<section id="features" className="scroll-mt-20 border-t border-border bg-card/10"> <section id="features" className="scroll-mt-20 border-t border-border bg-card/10">
{/* Feature 1 */} {/* Feature 1 */}
<div className="border-b border-border"> <div className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col-reverse items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32"> <div className="mx-auto flex w-full max-w-[1200px] flex-col-reverse items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32">
<div data-reveal className="w-full lg:w-1/2 relative"> <div data-reveal className="w-full lg:w-1/2 relative">
<div className="relative aspect-[16/10] w-full border border-border/50 bg-background overflow-hidden"> <div className="relative aspect-[16/10] w-full border border-border/50 bg-background overflow-hidden">
<Image src="/landing/compare-v2.webp" alt="Comparison Mode" fill className="object-cover object-left-top" sizes="(min-width: 1024px) 50vw, 100vw" /> <Image
src="/landing/compare-v2.webp"
alt="Comparison Mode"
fill
className="object-cover object-left-top"
sizes="(min-width: 1024px) 50vw, 100vw"
/>
<div className="absolute inset-0 bg-background/5" /> <div className="absolute inset-0 bg-background/5" />
</div> </div>
</div> </div>
<div data-reveal className="w-full lg:w-1/2 space-y-6"> <div data-reveal className="w-full lg:w-1/2 space-y-6">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Compare versions side-by-side. End &quot;which cut is this?&quot;</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Compare versions side-by-side. End &quot;which cut is this?&quot;
</h2>
<p className="text-base text-muted-foreground md:text-lg"> <p className="text-base text-muted-foreground md:text-lg">
See what actually changed between versions then approve with confidence. See what actually changed between versions then approve with confidence.
</p> </p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">Cuts revision cycles.</p> <p className="text-xs uppercase tracking-[0.14em] text-primary">
Cuts revision cycles.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -358,20 +415,29 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="border-b border-border bg-background"> <div className="border-b border-border bg-background">
<div className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32"> <div className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32">
<div data-reveal className="w-full lg:w-1/2 space-y-6"> <div data-reveal className="w-full lg:w-1/2 space-y-6">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Explain it better with Voice.</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Explain it better with Voice.
</h2>
<p className="text-base text-muted-foreground md:text-lg"> <p className="text-base text-muted-foreground md:text-lg">
No more &quot;What did you mean by this?&quot; emails. Every note lands at the exact moment in the video. No more &quot;What did you mean by this?&quot; emails. Every note lands at the
exact moment in the video.
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">
Faster feedback. Fewer misunderstandings.
</p> </p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">Faster feedback. Fewer misunderstandings.</p>
</div> </div>
<div data-reveal className="w-full lg:w-1/2"> <div data-reveal className="w-full lg:w-1/2">
<div className="border border-border bg-card p-6"> <div className="border border-border bg-card p-6">
<div className="border border-border/50 bg-background p-6"> <div className="border border-border/50 bg-background p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center bg-secondary font-mono text-xs">Y</div> <div className="flex h-8 w-8 items-center justify-center bg-secondary font-mono text-xs">
Y
</div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-mono text-[11px] font-medium leading-none">Yusuf İpek</p> <p className="font-mono text-[11px] font-medium leading-none">
Yusuf İpek
</p>
<p className="font-mono text-[10px] text-muted-foreground">00:03:45</p> <p className="font-mono text-[10px] text-muted-foreground">00:03:45</p>
</div> </div>
</div> </div>
@@ -382,7 +448,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<Play className="h-3 w-3" /> <Play className="h-3 w-3" />
</button> </button>
{Array.from({ length: 40 }).map((_, i) => ( {Array.from({ length: 40 }).map((_, i) => (
<span key={i} className="voice-bar w-full flex-1 bg-primary/60" style={{ height: `${[30, 80, 50, 90, 40, 70, 60, 45, 85, 55, 65, 35, 95, 75, 25, 40, 80, 50, 90, 30][i % 20]}%` }} /> <span
key={i}
className="voice-bar w-full flex-1 bg-primary/60"
style={{
height: `${[30, 80, 50, 90, 40, 70, 60, 45, 85, 55, 65, 35, 95, 75, 25, 40, 80, 50, 90, 30][i % 20]}%`,
}}
/>
))} ))}
</div> </div>
</div> </div>
@@ -394,12 +466,37 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{/* Feature 3 */} {/* Feature 3 */}
<div className="border-b border-border"> <div className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col-reverse items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32"> <div className="mx-auto flex w-full max-w-[1200px] flex-col-reverse items-center justify-between gap-12 px-4 py-20 sm:px-6 lg:flex-row lg:px-8 lg:py-32">
<div data-reveal className="w-full lg:w-1/2 relative aspect-video bg-card border border-border p-4"> <div
data-reveal
className="w-full lg:w-1/2 relative aspect-video bg-card border border-border p-4"
>
<div className="relative h-full w-full border border-border/50 overflow-hidden bg-background"> <div className="relative h-full w-full border border-border/50 overflow-hidden bg-background">
<Image src="https://images.unsplash.com/photo-1542204165-65bf26472b9b?auto=format&fit=crop&w=800&q=80" alt="Annotate" fill className="object-cover opacity-70" /> <Image
<svg className="absolute inset-0 h-full w-full pointer-events-none" viewBox="0 0 800 450" fill="none"> src="https://images.unsplash.com/photo-1542204165-65bf26472b9b?auto=format&fit=crop&w=800&q=80"
<circle cx="500" cy="225" r="80" stroke="#06b6d4" strokeWidth="4" className="opacity-90 drop-shadow-[0_0_8px_rgba(6,182,212,0.8)]" /> alt="Annotate"
<path d="M500 145 Q550 80 620 120" stroke="#06b6d4" strokeWidth="4" className="opacity-90" strokeLinecap="round" /> fill
className="object-cover opacity-70"
/>
<svg
className="absolute inset-0 h-full w-full pointer-events-none"
viewBox="0 0 800 450"
fill="none"
>
<circle
cx="500"
cy="225"
r="80"
stroke="#06b6d4"
strokeWidth="4"
className="opacity-90 drop-shadow-[0_0_8px_rgba(6,182,212,0.8)]"
/>
<path
d="M500 145 Q550 80 620 120"
stroke="#06b6d4"
strokeWidth="4"
className="opacity-90"
strokeLinecap="round"
/>
</svg> </svg>
{/* Circle Editor UI mock */} {/* Circle Editor UI mock */}
@@ -423,11 +520,16 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</div> </div>
</div> </div>
<div data-reveal className="w-full lg:w-1/2 space-y-6"> <div data-reveal className="w-full lg:w-1/2 space-y-6">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Point. Draw. Done.</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Point. Draw. Done.
</h2>
<p className="text-base text-muted-foreground md:text-lg"> <p className="text-base text-muted-foreground md:text-lg">
Precise feedback that leaves zero room for error. Circle, sketch, and point directly on the video frame. Precise feedback that leaves zero room for error. Circle, sketch, and point
directly on the video frame.
</p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">
Stops &quot;I thought you meant...&quot;
</p> </p>
<p className="text-xs uppercase tracking-[0.14em] text-primary">Stops &quot;I thought you meant...&quot;</p>
</div> </div>
</div> </div>
</div> </div>
@@ -437,16 +539,29 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border px-4 py-20 sm:px-6 lg:px-8 lg:py-32 bg-background"> <section className="border-b border-border px-4 py-20 sm:px-6 lg:px-8 lg:py-32 bg-background">
<div className="mx-auto w-full max-w-[1200px]"> <div className="mx-auto w-full max-w-[1200px]">
<div data-reveal className="mb-12 flex flex-col items-center text-center"> <div data-reveal className="mb-12 flex flex-col items-center text-center">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Everything a real production workflow needs.</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
<p className="mt-4 max-w-2xl text-base text-muted-foreground">The core tools teams expect without the complexity that slows clients down.</p> Everything a real production workflow needs.
</h2>
<p className="mt-4 max-w-2xl text-base text-muted-foreground">
The core tools teams expect without the complexity that slows clients down.
</p>
</div> </div>
<div className="space-y-10"> <div className="space-y-10">
<div> <div>
<p data-reveal className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground">Core workflow</p> <p
data-reveal
className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground"
>
Core workflow
</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{coreWorkflowFeatures.map((feat) => ( {coreWorkflowFeatures.map((feat) => (
<div key={feat.title} data-reveal className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80"> <div
key={feat.title}
data-reveal
className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80"
>
<feat.icon className="mb-4 h-6 w-6 text-primary" /> <feat.icon className="mb-4 h-6 w-6 text-primary" />
<h3 className="mb-2 text-lg font-medium">{feat.title}</h3> <h3 className="mb-2 text-lg font-medium">{feat.title}</h3>
<p className="text-sm text-muted-foreground">{feat.description}</p> <p className="text-sm text-muted-foreground">{feat.description}</p>
@@ -456,10 +571,19 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</div> </div>
<div> <div>
<p data-reveal className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground">Workflow Accelerators</p> <p
data-reveal
className="mb-4 text-xs uppercase tracking-[0.14em] text-muted-foreground"
>
Workflow Accelerators
</p>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{workflowAcceleratorFeatures.map((feat) => ( {workflowAcceleratorFeatures.map((feat) => (
<div key={feat.title} data-reveal className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80"> <div
key={feat.title}
data-reveal
className="group border border-border bg-card p-6 transition-colors hover:border-primary/50 hover:bg-card/80"
>
<feat.icon className="mb-4 h-6 w-6 text-primary" /> <feat.icon className="mb-4 h-6 w-6 text-primary" />
<h3 className="mb-2 text-lg font-medium">{feat.title}</h3> <h3 className="mb-2 text-lg font-medium">{feat.title}</h3>
<p className="text-sm text-muted-foreground">{feat.description}</p> <p className="text-sm text-muted-foreground">{feat.description}</p>
@@ -475,8 +599,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border bg-card/20 px-4 py-20 sm:px-6 lg:px-8 lg:py-32"> <section className="border-b border-border bg-card/20 px-4 py-20 sm:px-6 lg:px-8 lg:py-32">
<div className="mx-auto flex w-full max-w-[1200px] flex-col lg:flex-row gap-12 lg:items-center"> <div className="mx-auto flex w-full max-w-[1200px] flex-col lg:flex-row gap-12 lg:items-center">
<div data-reveal className="lg:w-1/2 space-y-6"> <div data-reveal className="lg:w-1/2 space-y-6">
<h2 className="text-4xl font-semibold tracking-[-0.02em] md:text-5xl">Clients don&apos;t need an account. They just review.</h2> <h2 className="text-4xl font-semibold tracking-[-0.02em] md:text-5xl">
<p className="text-lg text-muted-foreground">If clients can&apos;t adopt the tool, approvals don&apos;t happen. OpenFrame keeps it frictionless.</p> Clients don&apos;t need an account. They just review.
</h2>
<p className="text-lg text-muted-foreground">
If clients can&apos;t adopt the tool, approvals don&apos;t happen. OpenFrame keeps
it frictionless.
</p>
</div> </div>
<div data-reveal className="lg:w-1/2 space-y-4"> <div data-reveal className="lg:w-1/2 space-y-4">
<div className="flex items-start gap-4 border border-border bg-background p-6 transition-transform hover:-translate-y-1"> <div className="flex items-start gap-4 border border-border bg-background p-6 transition-transform hover:-translate-y-1">
@@ -494,7 +623,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="flex items-start gap-4 border border-border bg-background p-6 transition-transform hover:-translate-y-1"> <div className="flex items-start gap-4 border border-border bg-background p-6 transition-transform hover:-translate-y-1">
<MessageSquare className="mt-1 h-6 w-6 shrink-0 text-primary" /> <MessageSquare className="mt-1 h-6 w-6 shrink-0 text-primary" />
<div> <div>
<h3 className="text-lg font-semibold">Timestamped notes that are impossible to miss.</h3> <h3 className="text-lg font-semibold">
Timestamped notes that are impossible to miss.
</h3>
</div> </div>
</div> </div>
</div> </div>
@@ -502,41 +633,73 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</section> </section>
{/* 7) PRICING */} {/* 7) PRICING */}
<section id="pricing" className="border-b border-border bg-[#0a0a0a] px-4 py-20 sm:px-6 lg:px-8 lg:py-32"> <section
id="pricing"
className="border-b border-border bg-[#0a0a0a] px-4 py-20 sm:px-6 lg:px-8 lg:py-32"
>
<div className="mx-auto w-full max-w-[1200px]"> <div className="mx-auto w-full max-w-[1200px]">
<div data-reveal className="mb-16"> <div data-reveal className="mb-16">
<h2 className="text-3xl font-semibold md:text-5xl text-foreground" style={{ fontFamily: 'monospace', letterSpacing: '-0.02em' }}> <h2
className="text-3xl font-semibold md:text-5xl text-foreground"
style={{ fontFamily: 'monospace', letterSpacing: '-0.02em' }}
>
Deploy it yourself - or let us run it for you. Deploy it yourself - or let us run it for you.
</h2> </h2>
</div> </div>
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{/* Card 1: Open Source */} {/* Card 1: Open Source */}
<div data-reveal className="relative flex flex-col border border-border/40 bg-[#141414] p-8"> <div
data-reveal
className="relative flex flex-col border border-border/40 bg-[#141414] p-8"
>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-xl font-semibold text-foreground">Open Source (Self-hosted)</h3> <h3 className="text-xl font-semibold text-foreground">
Open Source (Self-hosted)
</h3>
</div> </div>
<div className="mb-8 flex items-baseline gap-2"> <div className="mb-8 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#06b6d4]">Free</span> <span className="text-3xl font-semibold text-[#06b6d4]">Free</span>
</div> </div>
<p className="mb-8 text-sm text-muted-foreground">For teams who want full control and can run their own infrastructure.</p> <p className="mb-8 text-sm text-muted-foreground">
For teams who want full control and can run their own infrastructure.
</p>
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80"> <ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
<li className="flex items-center gap-3"><CheckSquare className="h-4 w-4 text-[#06b6d4]" />Full codebase access</li> <li className="flex items-center gap-3">
<li className="flex items-center gap-3"><CheckSquare className="h-4 w-4 text-[#06b6d4]" />Self-hosted infrastructure control</li> <CheckSquare className="h-4 w-4 text-[#06b6d4]" />
<li className="flex items-center gap-3"><CheckSquare className="h-4 w-4 text-[#06b6d4]" />Manual update cadence</li> Full codebase access
</li>
<li className="flex items-center gap-3">
<CheckSquare className="h-4 w-4 text-[#06b6d4]" />
Self-hosted infrastructure control
</li>
<li className="flex items-center gap-3">
<CheckSquare className="h-4 w-4 text-[#06b6d4]" />
Manual update cadence
</li>
</ul> </ul>
<a href="https://github.com/yusufipk/OpenFrame" target="_blank" rel="noreferrer" className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden border border-border/50 bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 text-sm"> <a
href="https://github.com/yusufipk/OpenFrame"
target="_blank"
rel="noreferrer"
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden border border-border/50 bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 text-sm"
>
<Github className="mr-2 h-4 w-4" /> View on GitHub <Github className="mr-2 h-4 w-4" /> View on GitHub
</a> </a>
</div> </div>
{/* Card 2: Hosted Cloud */} {/* Card 2: Hosted Cloud */}
<div data-reveal className="relative flex flex-col border border-[#06b6d4]/40 bg-[#141414] p-8"> <div
data-reveal
className="relative flex flex-col border border-[#06b6d4]/40 bg-[#141414] p-8"
>
<div className="mb-6"> <div className="mb-6">
<p className="font-mono text-[10px] uppercase font-semibold text-muted-foreground tracking-widest mb-4">Best for teams who want zero setup.</p> <p className="font-mono text-[10px] uppercase font-semibold text-muted-foreground tracking-widest mb-4">
Best for teams who want zero setup.
</p>
<h3 className="text-xl font-semibold text-foreground">Hosted Cloud</h3> <h3 className="text-xl font-semibold text-foreground">Hosted Cloud</h3>
</div> </div>
@@ -546,40 +709,77 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
</div> </div>
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80"> <ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Unlimited collaborators</span></li> <li className="flex items-start gap-3">
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Timestamped comments + voice notes</span></li> <CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Annotations + version compare</span></li> <span>Unlimited collaborators</span>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Share links with permissions</span></li> </li>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Exports (PDF/CSV)</span></li> <li className="flex items-start gap-3">
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Unlimited YouTube Video Imports</span></li> <CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Download original uploaded video</span></li> <span>Timestamped comments + voice notes</span>
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Includes: 200 GB Storage</span></li> </li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Annotations + version compare</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Share links with permissions</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Exports (PDF/CSV)</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Unlimited YouTube Video Imports</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Download original uploaded video</span>
</li>
<li className="flex items-start gap-3">
<CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" />
<span>Includes: 200 GB Storage</span>
</li>
</ul> </ul>
<p className="mb-8 text-sm text-muted-foreground">Need more storage? Add 100 GB for $5/mo.</p> <p className="mb-8 text-sm text-muted-foreground">
Need more storage? Add 100 GB for $5/mo.
</p>
<Link href={hostedCtaHref} className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"> <Link
href={hostedCtaHref}
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
>
Start free Start free
</Link> </Link>
</div> </div>
{/* Card 3: Studio & Agency */} {/* Card 3: Studio & Agency */}
<div data-reveal className="relative flex flex-col border border-border/40 bg-[#141414] p-8"> <div
data-reveal
className="relative flex flex-col border border-border/40 bg-[#141414] p-8"
>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-xl font-semibold text-foreground">Need more than the standard limits?</h3> <h3 className="text-xl font-semibold text-foreground">
Need more than the standard limits?
</h3>
</div> </div>
<div className="mb-6 flex items-baseline gap-2"> <div className="mb-6 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#06b6d4]">Let&apos;s talk</span> <span className="text-3xl font-semibold text-[#06b6d4]">Let&apos;s talk</span>
</div> </div>
<p className="mb-8 text-sm text-foreground/80 leading-relaxed"> <p className="mb-8 text-sm text-foreground/80 leading-relaxed">
Custom capacity and setup help for high-volume production teams. Tell us your storage, usage, and workflow - we&apos;ll recommend the right approach. Custom capacity and setup help for high-volume production teams. Tell us your
storage, usage, and workflow - we&apos;ll recommend the right approach.
</p> </p>
<a href="mailto:[email protected]" className="group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 border border-border/50 text-sm"> <a
href="mailto:[email protected]"
className="group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#0a0a0a] font-medium text-foreground transition-colors hover:bg-white/5 border border-border/50 text-sm"
>
Contact us Contact us
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
@@ -588,7 +788,9 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<section className="border-b border-border bg-background px-4 py-20 sm:px-6 lg:px-8 lg:py-24"> <section className="border-b border-border bg-background px-4 py-20 sm:px-6 lg:px-8 lg:py-24">
<div className="mx-auto w-full max-w-[1200px]"> <div className="mx-auto w-full max-w-[1200px]">
<div data-reveal className="mx-auto max-w-4xl"> <div data-reveal className="mx-auto max-w-4xl">
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">Security & privacy, by design.</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-5xl">
Security & privacy, by design.
</h2>
<ul className="mt-8 space-y-4 text-base text-muted-foreground md:text-lg"> <ul className="mt-8 space-y-4 text-base text-muted-foreground md:text-lg">
<li>- Permissioned share links (control who can view/comment)</li> <li>- Permissioned share links (control who can view/comment)</li>
<li>- Private-by-default projects</li> <li>- Private-by-default projects</li>
@@ -612,7 +814,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
}, },
{ {
q: 'What happens if I exceed my storage?', q: 'What happens if I exceed my storage?',
a: 'You can add 100 GB for $5/mo. If you need much more, contact us at [email protected] and we\'ll help you choose the best setup.', a: "You can add 100 GB for $5/mo. If you need much more, contact us at [email protected] and we'll help you choose the best setup.",
}, },
{ {
q: 'How is YouTube unlimited?', q: 'How is YouTube unlimited?',
@@ -655,10 +857,17 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
{/* 10) FINAL CTA STRIP */} {/* 10) FINAL CTA STRIP */}
<section className="border-b border-border bg-background px-4 py-16 sm:px-6 lg:px-8"> <section className="border-b border-border bg-background px-4 py-16 sm:px-6 lg:px-8">
<div data-reveal className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left"> <div
data-reveal
className="mx-auto flex w-full max-w-[1200px] flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left"
>
<div> <div>
<h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-4xl">Stop chasing feedback. Start getting approvals.</h2> <h2 className="text-3xl font-semibold tracking-[-0.02em] md:text-4xl">
<p className="mt-2 text-sm text-muted-foreground">Your first review link takes minutes.</p> Stop chasing feedback. Start getting approvals.
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Your first review link takes minutes.
</p>
</div> </div>
<Link <Link
href={hostedCtaHref} href={hostedCtaHref}
@@ -674,14 +883,32 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="mx-auto flex max-w-[1200px] flex-col items-center justify-between gap-4 sm:flex-row"> <div className="mx-auto flex max-w-[1200px] flex-col items-center justify-between gap-4 sm:flex-row">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Video className="h-4 w-4 text-primary" /> <Video className="h-4 w-4 text-primary" />
<span className="font-mono text-xs text-muted-foreground">© 2026 IPEK TECH LLC. All rights reserved.</span> <span className="font-mono text-xs text-muted-foreground">
© 2026 IPEK TECH LLC. All rights reserved.
</span>
</div> </div>
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">
<a href="mailto:[email protected]" className="text-xs text-muted-foreground hover:text-foreground">info@open-frame.net</a> <a
<a href="https://github.com/yusufipk/OpenFrame" className="text-xs text-muted-foreground hover:text-foreground">GitHub</a> href="mailto:info@open-frame.net"
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground">Terms</Link> className="text-xs text-muted-foreground hover:text-foreground"
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground">Privacy</Link> >
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground">Refund Policy</Link> info@open-frame.net
</a>
<a
href="https://github.com/yusufipk/OpenFrame"
className="text-xs text-muted-foreground hover:text-foreground"
>
GitHub
</a>
<Link href="/terms" className="text-xs text-muted-foreground hover:text-foreground">
Terms
</Link>
<Link href="/privacy" className="text-xs text-muted-foreground hover:text-foreground">
Privacy
</Link>
<Link href="/refund" className="text-xs text-muted-foreground hover:text-foreground">
Refund Policy
</Link>
</div> </div>
</div> </div>
</footer> </footer>
+5 -1
View File
@@ -64,7 +64,11 @@ export function DeleteFeedbackButton({
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant={variant} size={size} disabled={isDeleting}> <Button variant={variant} size={size} disabled={isDeleting}>
{isDeleting ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1.5 h-4 w-4" />} {isDeleting ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-4 w-4" />
)}
Delete Delete
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
+7 -1
View File
@@ -36,7 +36,13 @@ export function RefreshR2StatsButton() {
return ( return (
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<Button type="button" variant="outline" size="sm" onClick={handleRefresh} disabled={isRefreshing}> <Button
type="button"
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? ( {isRefreshing ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : ( ) : (
+90 -30
View File
@@ -31,7 +31,11 @@ const MAX_WIDTH = 10;
// Reference canvas width for stroke scaling // Reference canvas width for stroke scaling
const REF_WIDTH = 1000; const REF_WIDTH = 1000;
export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) { export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(
function AnnotationCanvas(
{ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss },
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [strokes, setStrokes] = useState<AnnotationStroke[]>(initialStrokes || []); const [strokes, setStrokes] = useState<AnnotationStroke[]>(initialStrokes || []);
@@ -42,12 +46,22 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
void onConfirm; void onConfirm;
// Expose getStrokes so parent can grab current drawing without confirm // Expose getStrokes so parent can grab current drawing without confirm
useImperativeHandle(ref, () => ({ useImperativeHandle(
ref,
() => ({
getStrokes: () => strokes, getStrokes: () => strokes,
}), [strokes]); }),
[strokes]
);
// Render all strokes // Render all strokes
const renderStrokes = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, strokeList: AnnotationStroke[], active?: AnnotationStroke | null) => { const renderStrokes = useCallback(
(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
strokeList: AnnotationStroke[],
active?: AnnotationStroke | null
) => {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
const scale = canvas.width / REF_WIDTH; const scale = canvas.width / REF_WIDTH;
@@ -68,15 +82,21 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
strokeList.forEach(draw); strokeList.forEach(draw);
if (active) draw(active); if (active) draw(active);
}, []); },
[]
);
// Keep a stable ref to the latest strokes/currentStroke so the resize // Keep a stable ref to the latest strokes/currentStroke so the resize
// handler can redraw without being listed as a dependency (which would // handler can redraw without being listed as a dependency (which would
// re-register the listener on every stroke change). // re-register the listener on every stroke change).
const strokesRef = useRef(strokes); const strokesRef = useRef(strokes);
const currentStrokeRef = useRef(currentStroke); const currentStrokeRef = useRef(currentStroke);
useEffect(() => { strokesRef.current = strokes; }, [strokes]); useEffect(() => {
useEffect(() => { currentStrokeRef.current = currentStroke; }, [currentStroke]); strokesRef.current = strokes;
}, [strokes]);
useEffect(() => {
currentStrokeRef.current = currentStroke;
}, [currentStroke]);
// Resize canvas to match container — listener registered once, never re-added. // Resize canvas to match container — listener registered once, never re-added.
useEffect(() => { useEffect(() => {
@@ -125,7 +145,8 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
}; };
}, []); }, []);
const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => { const handlePointerDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (mode !== 'draw') return; if (mode !== 'draw') return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -133,35 +154,43 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
if (!pt) return; if (!pt) return;
isDrawingRef.current = true; isDrawingRef.current = true;
setCurrentStroke({ points: [pt], color, width }); setCurrentStroke({ points: [pt], color, width });
}, [mode, color, width, getPoint]); },
[mode, color, width, getPoint]
);
const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => { const handlePointerMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (mode !== 'draw' || !isDrawingRef.current) return; if (mode !== 'draw' || !isDrawingRef.current) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const pt = getPoint(e); const pt = getPoint(e);
if (!pt) return; if (!pt) return;
setCurrentStroke(prev => { setCurrentStroke((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { ...prev, points: [...prev.points, pt] }; return { ...prev, points: [...prev.points, pt] };
}); });
}, [mode, getPoint]); },
[mode, getPoint]
);
const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => { const handlePointerUp = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (mode !== 'draw' || !isDrawingRef.current) return; if (mode !== 'draw' || !isDrawingRef.current) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
isDrawingRef.current = false; isDrawingRef.current = false;
setCurrentStroke(prev => { setCurrentStroke((prev) => {
if (prev && prev.points.length >= 2) { if (prev && prev.points.length >= 2) {
setStrokes(s => [...s, prev]); setStrokes((s) => [...s, prev]);
} }
return null; return null;
}); });
}, [mode]); },
[mode]
);
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
setStrokes(prev => prev.slice(0, -1)); setStrokes((prev) => prev.slice(0, -1));
}, []); }, []);
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
@@ -169,12 +198,15 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
}, []); }, []);
// View mode: click to dismiss // View mode: click to dismiss
const handleViewClick = useCallback((e: React.MouseEvent) => { const handleViewClick = useCallback(
(e: React.MouseEvent) => {
if (mode === 'view') { if (mode === 'view') {
e.stopPropagation(); e.stopPropagation();
onDismiss?.(); onDismiss?.();
} }
}, [mode, onDismiss]); },
[mode, onDismiss]
);
if (mode === 'view') { if (mode === 'view') {
return ( return (
@@ -184,10 +216,7 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
onClick={handleViewClick} onClick={handleViewClick}
title="Click to dismiss annotation" title="Click to dismiss annotation"
> >
<canvas <canvas ref={canvasRef} className="w-full h-full pointer-events-none" />
ref={canvasRef}
className="w-full h-full pointer-events-none"
/>
</div> </div>
); );
} }
@@ -214,7 +243,7 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center justify-center flex-wrap gap-x-2 gap-y-2 w-[calc(100%-24px)] max-w-fit bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-[70]"> <div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center justify-center flex-wrap gap-x-2 gap-y-2 w-[calc(100%-24px)] max-w-fit bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-[70]">
{/* Colors */} {/* Colors */}
<div className="flex items-center justify-center flex-wrap gap-1.5"> <div className="flex items-center justify-center flex-wrap gap-1.5">
{COLORS.map(c => ( {COLORS.map((c) => (
<button <button
key={c} key={c}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0" className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
@@ -232,11 +261,21 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
{/* Brush size */} {/* Brush size */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.max(MIN_WIDTH, w - 1))}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setWidth((w) => Math.max(MIN_WIDTH, w - 1))}
>
<Minus className="h-3 w-3" /> <Minus className="h-3 w-3" />
</Button> </Button>
<span className="text-xs tabular-nums w-4 text-center">{width}</span> <span className="text-xs tabular-nums w-4 text-center">{width}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.min(MAX_WIDTH, w + 1))}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setWidth((w) => Math.min(MAX_WIDTH, w + 1))}
>
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
</div> </div>
@@ -245,10 +284,24 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleUndo} disabled={strokes.length === 0} title="Undo"> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleUndo}
disabled={strokes.length === 0}
title="Undo"
>
<Undo2 className="h-4 w-4" /> <Undo2 className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:bg-destructive/10" onClick={handleClear} disabled={strokes.length === 0} title="Clear all"> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={handleClear}
disabled={strokes.length === 0}
title="Clear all"
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -256,10 +309,17 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
<div className="hidden sm:block w-px h-6 bg-border mx-1" /> <div className="hidden sm:block w-px h-6 bg-border mx-1" />
{/* Close */} {/* Close */}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel} title="Close annotation tool"> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onCancel}
title="Close annotation tool"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
); );
}); }
);

Some files were not shown because too many files have changed in this diff Show More