diff --git a/Dockerfile b/Dockerfile index 9da08df6..5caa443a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN npm install -g pnpm && pnpm install # Last supported deno version for netlify is 2.2.4 RUN npm install -g deno@2.2.4 -RUN npm install -g sst +RUN npm install -g sst@3.17.14 RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen diff --git a/app/api/slugs/route.ts b/app/api/slugs/route.ts new file mode 100644 index 00000000..2273ba79 --- /dev/null +++ b/app/api/slugs/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUserId } from '~/auth/session'; +import { generateDefaultSlug, generateUniqueSlug, isValidSlug } from '~/utils/slug'; +import { prisma } from '~/lib/prisma'; + +export async function POST(request: NextRequest) { + try { + await requireUserId(request); + + const { baseSlug, chatId, siteName, existingSlug } = await request.json(); // TODO: type + + // Validate input + if (!baseSlug && !chatId) { + return NextResponse.json({ error: 'Either baseSlug or chatId is required' }, { status: 400 }); + } + + // Generate base slug if not provided + const slugToUse = baseSlug || generateDefaultSlug(chatId, siteName); + + // Validate the base slug + if (!isValidSlug(slugToUse)) { + return NextResponse.json({ error: 'Invalid slug format' }, { status: 400 }); + } + + // Generate unique slug + const uniqueSlug = await generateUniqueSlug(slugToUse, existingSlug); + + return NextResponse.json({ slug: uniqueSlug }); + } catch (error) { + console.error('Error generating slug:', error); + return NextResponse.json({ error: 'Failed to generate slug' }, { status: 500 }); + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const slug = searchParams.get('slug'); + + if (!slug) { + return NextResponse.json({ error: 'Slug parameter is required' }, { status: 400 }); + } + + // Validate slug format + const isValid = isValidSlug(slug); + + if (!isValid) { + return NextResponse.json({ isValid: false, isAvailable: false }); + } + + // Check if slug is available (not used by other websites) + const existingWebsite = await prisma.website.findFirst({ + where: { + slug, + }, + select: { + id: true, + }, + }); + + const isAvailable = !existingWebsite; + + return NextResponse.json({ + isValid: true, + isAvailable, + message: isAvailable ? 'Slug is available' : 'Slug is already taken', + }); + } catch (error) { + console.error('Error validating slug:', error); + return NextResponse.json({ error: 'Failed to validate slug' }, { status: 500 }); + } +} diff --git a/app/api/validate-url/route.ts b/app/api/validate-url/route.ts new file mode 100644 index 00000000..b8d106dd --- /dev/null +++ b/app/api/validate-url/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateDeploymentUrl } from '~/lib/utils/app-url'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ success: false, error: 'URL parameter is required' }, { status: 400 }); + } + + // Validate the URL format + try { + new URL(url); + } catch { + return NextResponse.json({ success: false, error: 'Invalid URL format' }, { status: 400 }); + } + + // Validate the deployment URL + const result = await validateDeploymentUrl(url); + + return NextResponse.json({ + success: true, + valid: result.valid, + error: result.error, + }); + } catch (error) { + console.error('URL validation error:', error); + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/websites/[id]/route.ts b/app/api/websites/[id]/route.ts index dc13838f..39fb5484 100644 --- a/app/api/websites/[id]/route.ts +++ b/app/api/websites/[id]/route.ts @@ -1,136 +1,126 @@ import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; -import { PermissionAction, PermissionResource, Prisma } from '@prisma/client'; -import { subject } from '@casl/ability'; -import { requireUserAbility } from '~/auth/session'; -import { deleteWebsite, getWebsite, updateWebsite } from '~/lib/services/websiteService'; -import { env } from '~/env'; +import { requireUserId } from '~/auth/session'; +import { prisma } from '~/lib/prisma'; import { logger } from '~/utils/logger'; +import { generateUniqueSlug } from '~/utils/slug'; +import { z } from 'zod'; -// Netlify API client -const NETLIFY_API_URL = 'https://api.netlify.com/api/v1'; -const NETLIFY_ACCESS_TOKEN = env.server.NETLIFY_AUTH_TOKEN; - -async function deleteNetlifySite(siteId: string) { - if (!NETLIFY_ACCESS_TOKEN) { - throw new Error('NETLIFY_ACCESS_TOKEN is not configured'); - } - - const response = await fetch(`${NETLIFY_API_URL}/sites/${siteId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${NETLIFY_ACCESS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const error = (await response.json()) as any; - throw new Error(`Failed to delete Netlify site: ${error.message || response.statusText}`); - } - - return true; -} - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { userAbility } = await requireUserAbility(request); - const { id } = await params; - - const website = await getWebsite(id); - - if ( - userAbility.cannot(PermissionAction.read, subject(PermissionResource.Website, website)) && - userAbility.cannot( - PermissionAction.read, - subject(PermissionResource.Environment, { id: website.environmentId ?? undefined }), - ) - ) { - return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); - } - - return NextResponse.json({ success: true, website }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - return NextResponse.json({ success: false, error: 'Website not found' }, { status: 404 }); - } - - return NextResponse.json({ success: false, error: 'Failed to fetch website' }, { status: 500 }); - } -} - -const patchRequestSchema = z.object({ - siteName: z.string().min(1, 'Site name is required'), +const requestBodySchema = z.object({ + siteName: z.string().min(1).max(100).optional(), + slug: z.string().min(1).max(50).optional(), }); export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const { userAbility } = await requireUserAbility(request); + const userId = await requireUserId(request); const { id } = await params; - const body = await request.json(); - const parsedBody = patchRequestSchema.safeParse(body); - - if (!parsedBody.success) { - return NextResponse.json({ success: false, error: parsedBody.error.flatten().fieldErrors }, { status: 400 }); + const parseResult = requestBodySchema.parse(body); + const { siteName, slug } = parseResult; + + // Verify the website belongs to the user + const existingWebsite = await prisma.website.findFirst({ + where: { + id, + createdById: userId, + }, + }); + + if (!existingWebsite) { + return NextResponse.json({ error: 'Website not found' }, { status: 404 }); } - const website = await getWebsite(id); + // Prepare update data + const updateData: any = {}; - if ( - userAbility.cannot(PermissionAction.update, subject(PermissionResource.Website, website)) && - userAbility.cannot( - PermissionAction.update, - subject(PermissionResource.Environment, { id: website.environmentId ?? undefined }), - ) - ) { - return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + if (siteName !== undefined) { + updateData.siteName = siteName; } - const updatedWebsite = await updateWebsite(id, parsedBody.data); - - return NextResponse.json({ success: true, website: updatedWebsite }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - return NextResponse.json({ success: false, error: `Website not found` }, { status: 404 }); + if (slug !== undefined) { + if (slug && slug.trim()) { + // Check if slug is already taken by another website + const existingSlugWebsite = await prisma.website.findFirst({ + where: { + slug: slug.trim(), + id: { not: id }, // Exclude current website + }, + }); + + if (existingSlugWebsite) { + return NextResponse.json({ error: 'Slug is already taken' }, { status: 400 }); + } + + updateData.slug = slug.trim(); + } else { + // Generate a new slug if empty + const baseSlug = siteName || existingWebsite.siteName || 'untitled-app'; + updateData.slug = await generateUniqueSlug(baseSlug, existingWebsite.slug || undefined); + } } - return NextResponse.json({ success: false, error: 'Failed to update website' }, { status: 500 }); + // Update the website + const updatedWebsite = await prisma.website.update({ + where: { id }, + data: updateData, + include: { + environment: { + select: { + id: true, + name: true, + }, + }, + createdBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + return NextResponse.json({ + success: true, + website: updatedWebsite, + }); + } catch (error) { + logger.error('Error updating website', { + error: error instanceof Error ? error.message : String(error), + websiteId: (await params).id, + }); + return NextResponse.json({ error: 'Failed to update website' }, { status: 500 }); } } export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const { userAbility } = await requireUserAbility(request); + const userId = await requireUserId(request); const { id } = await params; - const website = await getWebsite(id); - - if ( - userAbility.cannot(PermissionAction.delete, subject(PermissionResource.Website, website)) && - userAbility.cannot( - PermissionAction.delete, - subject(PermissionResource.Environment, { id: website.environmentId ?? undefined }), - ) - ) { - return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); - } + // Verify the website belongs to the user + const existingWebsite = await prisma.website.findFirst({ + where: { + id, + createdById: userId, + }, + }); - // Delete from Netlify first - if (website?.siteId) { - await deleteNetlifySite(website.siteId); + if (!existingWebsite) { + return NextResponse.json({ error: 'Website not found' }, { status: 404 }); } - await deleteWebsite(id); + // Delete the website + await prisma.website.delete({ + where: { id }, + }); return NextResponse.json({ success: true }); } catch (error) { - logger.error('Error deleting website:', error); - - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - return NextResponse.json({ success: false, error: `Website not found` }, { status: 404 }); - } - - return NextResponse.json({ success: false, error: 'Failed to delete website' }, { status: 500 }); + logger.error('Error deleting website', { + error: error instanceof Error ? error.message : String(error), + websiteId: (await params).id, + }); + return NextResponse.json({ error: 'Failed to delete website' }, { status: 500 }); } } diff --git a/app/api/websites/route.ts b/app/api/websites/route.ts index e878dccc..22f6d429 100644 --- a/app/api/websites/route.ts +++ b/app/api/websites/route.ts @@ -1,55 +1,60 @@ import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; +import { requireUserId } from '~/auth/session'; +import { prisma } from '~/lib/prisma'; import { logger } from '~/utils/logger'; -import { requireUserAbility } from '~/auth/session'; -import { createWebsite, getWebsites } from '~/lib/services/websiteService'; -import { PermissionAction, PermissionResource } from '@prisma/client'; export async function GET(request: NextRequest) { - const { userAbility } = await requireUserAbility(request); - - if (userAbility.cannot(PermissionAction.read, PermissionResource.Website)) { - return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); - } - - const websites = await getWebsites(userAbility); - - return NextResponse.json({ success: true, websites }); -} - -const postRequestSchema = z.object({ - chatId: z.string().min(1, 'Chat ID is required'), - createdById: z.string().min(1, 'User ID is required'), - siteId: z.string().optional().default(''), - siteName: z.string().optional().default(''), - siteUrl: z.string().optional().default(''), -}); - -export async function POST(request: NextRequest) { try { - const { userAbility } = await requireUserAbility(request); - - const body = await request.json(); - const parsedBody = postRequestSchema.safeParse(body); - - if (!parsedBody.success) { - return NextResponse.json({ success: false, error: parsedBody.error.flatten().fieldErrors }, { status: 400 }); - } - - if (userAbility.cannot(PermissionAction.create, PermissionResource.Website)) { - return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + const userId = await requireUserId(request); + const { searchParams } = new URL(request.url); + const slug = searchParams.get('slug'); + + if (slug) { + // Check if slug is available + const websites = await prisma.website.findMany({ + where: { + slug, + }, + select: { + id: true, + slug: true, + }, + }); + + return NextResponse.json({ websites }); } - const website = await createWebsite(parsedBody.data); - - logger.info( - 'Website created successfully', - JSON.stringify({ chatId: parsedBody.data.chatId, websiteId: website.id }), - ); - - return NextResponse.json({ success: true, website }); + // Get all websites for the user + const websites = await prisma.website.findMany({ + where: { + createdById: userId, + }, + include: { + environment: { + select: { + id: true, + name: true, + }, + }, + createdBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + return NextResponse.json({ + success: true, + websites, + }); } catch (error) { - logger.error('Failed to create website', error); - return NextResponse.json({ success: false, error: 'Failed to create website' }, { status: 500 }); + logger.error('Error fetching websites', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to fetch websites' }, { status: 500 }); } } diff --git a/app/apps/[slug]/AppViewer.tsx b/app/apps/[slug]/AppViewer.tsx new file mode 100644 index 00000000..bb985fd6 --- /dev/null +++ b/app/apps/[slug]/AppViewer.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { AlertCircle, ExternalLink, Globe, Loader2, Maximize2, Minimize2, RefreshCw, Settings } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +type Website = any; // TODO + +interface AppViewerProps { + website: Website; +} + +export default function AppViewer({ website }: AppViewerProps) { + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showInfo, setShowInfo] = useState(false); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + const handleIframeLoad = () => { + setIsLoading(false); + setHasError(false); + }; + + const handleIframeError = () => { + setIsLoading(false); + setHasError(true); + }; + + const handleRefresh = () => { + setIsLoading(true); + setHasError(false); + setLastRefresh(new Date()); + + // Force iframe reload by updating the src + const iframe = document.getElementById('app-iframe') as HTMLIFrameElement; + + if (iframe) { + const currentSrc = iframe.src; + iframe.src = ''; + setTimeout(() => { + iframe.src = currentSrc; + }, 100); + } + }; + + const handleOpenExternal = () => { + if (website.siteUrl) { + window.open(website.siteUrl, '_blank', 'noopener,noreferrer'); + } + }; + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + setIsFullscreen(true); + } else { + document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + if (!website.siteUrl) { + return ( +
+
+ +

App Not Deployed

+

This app hasn't been deployed yet.

+
+
+ ); + } + + return ( +
+ {/* Header */} + {/* TODO: remove whole header? */} +
+
+
+
+ +
+
+

+ {website.siteName || 'Untitled App'} +

+
+
+ +
+ + + + + + + +
+
+
+ + {/* App Information Panel */} + {showInfo && ( + +
+
+ Created by: +

{website.createdBy.name}

+
+
+ Created: +

{new Date(website.createdAt).toLocaleDateString()}

+
+
+ Last refreshed: +

{lastRefresh.toLocaleTimeString()}

+
+
+
+ )} + + {/* Loading State */} + {isLoading && ( +
+
+ +

Loading app...

+
+
+ )} + + {/* Error State */} + {hasError && ( +
+
+ +

Failed to Load App

+

+ The deployed app couldn't be loaded. It might be offline or the URL is incorrect. +

+ +
+
+ )} + + {/* App Container */} +
+