diff --git a/app/(dashboard)/projects/[id]/_components/settings-layout.tsx b/app/(dashboard)/projects/[id]/_components/settings-layout.tsx new file mode 100644 index 0000000..aca701c --- /dev/null +++ b/app/(dashboard)/projects/[id]/_components/settings-layout.tsx @@ -0,0 +1,47 @@ +/** + * Settings page layout component + * Used for project settings pages (database, environments, etc.) + * VSCode Dark Modern style with clean design + */ + +'use client'; + +import type { ReactNode } from 'react'; + +import { Skeleton } from '@/components/ui/skeleton'; + +interface SettingsLayoutProps { + title: string; + description: string; + children: ReactNode; + loading?: boolean; +} + +/** + * Layout wrapper for project settings pages + * Uses skeleton to maintain layout stability during loading + */ +export function SettingsLayout({ title, description, children, loading }: SettingsLayoutProps) { + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ {loading ? ( + <> + + + + + ) : ( + children + )} +
+
+
+ ); +} diff --git a/app/(dashboard)/projects/[id]/auth/page.tsx b/app/(dashboard)/projects/[id]/auth/page.tsx index 984930a..7ab31ca 100644 --- a/app/(dashboard)/projects/[id]/auth/page.tsx +++ b/app/(dashboard)/projects/[id]/auth/page.tsx @@ -1,7 +1,6 @@ /** * Authentication Configuration Page * Configure OAuth providers and NextAuth settings - * VSCode Dark Modern style */ 'use client'; @@ -10,7 +9,6 @@ import { ExternalLink, Key } from 'lucide-react'; import { Github } from 'lucide-react'; import { useParams } from 'next/navigation'; -import { ConfigLayout } from '@/components/config/config-layout'; import { EnvVarSection } from '@/components/config/env-var-section'; import { useBatchUpdateEnvironmentVariables, @@ -18,6 +16,8 @@ import { } from '@/hooks/use-environment-variables'; import { useProject } from '@/hooks/use-project'; +import { SettingsLayout } from '../_components/settings-layout'; + /** * Generate a secure random secret */ @@ -87,24 +87,24 @@ function AuthPageContent() { ]; return ( - -
+
{/* GitHub OAuth Section */}
- -

GitHub OAuth

+ +

GitHub OAuth

GitHub Developer Settings @@ -121,9 +121,9 @@ function AuthPageContent() { /> {/* Setup Instructions */} -
-

Setup Instructions

-
    +
    +

    Setup Instructions

    +
    1. Go to GitHub Settings → Developer settings → OAuth Apps
    2. Click "New OAuth App" or select an existing app
    3. Set the Homepage URL and Authorization callback URL
    4. @@ -134,13 +134,13 @@ function AuthPageContent() {
    {/* Divider */} -
    +
    {/* NextAuth Configuration Section */}
    - -

    NextAuth Configuration

    + +

    NextAuth Configuration

    {/* Important Notes */} -
    -

    Important Notes

    -
      +
      +

      Important Notes

      +
      • The NextAuth URL must match your application URL exactly
      • The secret should be at least 32 characters long
      • Never commit your NEXTAUTH_SECRET to version control
      • @@ -164,7 +164,7 @@ function AuthPageContent() {
    - + ); } diff --git a/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx b/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx new file mode 100644 index 0000000..e37ec94 --- /dev/null +++ b/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + MdCheck, + MdContentCopy, + MdInfo, + MdVisibility, + MdVisibilityOff, +} from 'react-icons/md'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface ConnectionStringProps { + connectionString: string; +} + +export function ConnectionString({ connectionString }: ConnectionStringProps) { + const [isVisible, setIsVisible] = useState(false); + const [copied, setCopied] = useState(false); + + // Memoize masked string to avoid recalculation on every render + const displayValue = useMemo(() => { + if (isVisible) return connectionString; + return '•'.repeat(Math.min(connectionString.length, 50)); + }, [connectionString, isVisible]); + + const handleCopy = async () => { + await navigator.clipboard.writeText(connectionString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
    + {/* Header with title and badge */} +
    +

    + Full Connection String +

    + + Read-only + +
    + + {/* Connection string display */} +
    +
    + {displayValue} +
    + + {/* Action buttons */} +
    + {/* Toggle visibility */} + + + {/* Copy button */} + +
    +
    + + {/* Footer with info */} +

    + + Use this connection string in your application to connect to the database. +

    +
    + ); +} diff --git a/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx b/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx new file mode 100644 index 0000000..73c6599 --- /dev/null +++ b/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx @@ -0,0 +1,58 @@ +import { type IconType } from 'react-icons'; +import { MdAutoAwesome, MdHttps, MdStorage, MdTerminal } from 'react-icons/md'; + +// Feature card component +function FeatureCard({ + icon: Icon, + title, + description, +}: { + icon: IconType; + title: string; + description: string; +}) { + return ( +
    +
    + +
    +

    {title}

    +

    {description}

    +
    + ); +} + +// Feature cards data +const DATABASE_FEATURES = [ + { + icon: MdAutoAwesome, + title: 'Auto Provisioned', + description: 'Database is automatically provisioned and ready to use with your sandbox environment.', + }, + { + icon: MdStorage, + title: 'High Availability', + description: 'Managed by KubeBlocks with high availability and automatic failover.', + }, + { + icon: MdHttps, + title: 'SSL Encrypted', + description: 'SSL encryption enabled by default for secure database connections.', + }, + { + icon: MdTerminal, + title: 'Environment Variable', + description: 'Connection string available via DATABASE_URL environment variable.', + }, +] as const; + +// Export complete component +export function FeatureCards() { + return ( +
    + {DATABASE_FEATURES.map(({ icon, title, description }) => ( + + ))} +
    + ); +} diff --git a/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx b/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx new file mode 100644 index 0000000..f923c8a --- /dev/null +++ b/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx @@ -0,0 +1,39 @@ +/** + * Read-only field component for displaying data + * Mimics input styling for display-only scenarios + */ + +import { useId } from 'react'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface ReadOnlyFieldProps { + /** Field label */ + label: string; + /** Field value */ + value: string | number; + /** Full width on mobile (col-span-2) */ + fullWidth?: boolean; +} + +export function ReadOnlyField({ label, value, fullWidth }: ReadOnlyFieldProps) { + const fieldId = useId(); + + return ( +
    + + +
    + ); +} diff --git a/app/(dashboard)/projects/[id]/database/connection-string.tsx b/app/(dashboard)/projects/[id]/database/connection-string.tsx deleted file mode 100644 index 74d13e0..0000000 --- a/app/(dashboard)/projects/[id]/database/connection-string.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Eye, EyeOff } from 'lucide-react'; - -import { Input } from '@/components/ui/input'; - -interface ConnectionStringProps { - connectionString: string; -} - -export function ConnectionString({ connectionString }: ConnectionStringProps) { - const [isVisible, setIsVisible] = useState(false); - - return ( -
    - -
    - - -
    -

    - Use this connection string in your application to connect to the database -

    -
    - ); -} diff --git a/app/(dashboard)/projects/[id]/database/page.tsx b/app/(dashboard)/projects/[id]/database/page.tsx index 1485e8e..edc13f4 100644 --- a/app/(dashboard)/projects/[id]/database/page.tsx +++ b/app/(dashboard)/projects/[id]/database/page.tsx @@ -1,132 +1,68 @@ -/** - * Database Information Page - * Display-only page showing database connection details - * VSCode Dark Modern style - */ - -import { Info } from 'lucide-react'; import { notFound, redirect } from 'next/navigation'; -import { ConfigLayout } from '@/components/config/config-layout'; import { auth } from '@/lib/auth'; -import { prisma } from '@/lib/db'; +import { parseConnectionUrl } from '@/lib/data/database'; +import { getProject } from '@/lib/data/project'; + +import { SettingsLayout } from '../_components/settings-layout'; -import { ConnectionString } from './connection-string'; +import { ConnectionString } from './_components/connection-string'; +import { FeatureCards } from './_components/feature-cards'; +import { ReadOnlyField } from './_components/read-only-field'; export default async function DatabasePage({ params }: { params: Promise<{ id: string }> }) { - const session = await auth(); + // Parallel: fetch session and params simultaneously + const [session, paramsResolved] = await Promise.all([ + auth(), + params + ]); + if (!session) redirect('/login'); - const { id } = await params; + const { id } = paramsResolved; - const project = await prisma.project.findFirst({ - where: { id, userId: session.user.id }, - include: { databases: true, environments: true }, + const project = await getProject(id, session.user.id, { + databases: true, }); if (!project) notFound(); const database = project.databases[0]; - const connectionString = database?.connectionUrl || ''; - let host = ''; - let port = ''; - let dbName = ''; - let username = ''; - let password = ''; - - // Parse connection string - if (connectionString) { - try { - const match = connectionString.match(/postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/); - if (match) { - [, username, password, host, port, dbName] = match; - } - } catch (e) { - console.error('Failed to parse database URL:', e); - } - } + const connectionInfo = parseConnectionUrl(connectionString) || { + host: '', port: '', database: '', username: '', password: '' + }; return ( - -
    - {/* Content */} -
    -
    - {connectionString ? ( - <> - {/* Connection Details */} -
    -

    PostgreSQL Connection

    - - {/* Host */} -
    - -
    - {host} -
    -
    - - {/* Port */} -
    - -
    - {port} -
    -
    - - {/* Database Name */} -
    - -
    - {dbName} -
    -
    - - {/* Username */} -
    - -
    - {username} -
    -
    - - {/* Password */} -
    - -
    - {'•'.repeat(Math.min(password.length, 20))} -
    -
    - - {/* Full Connection String */} - -
    - - {/* Info Panel */} -
    -
    - -
    -

    • Database is automatically provisioned with your sandbox

    -

    • Managed by KubeBlocks with high availability

    -

    • SSL encryption enabled by default

    -

    • Connection string available via DATABASE_URL environment variable

    -
    -
    -
    - - ) : ( -
    -

    No database configured

    -

    - Database will be automatically provisioned when sandbox is created -

    -
    - )} + + {connectionString ? ( + <> +
    +

    PostgreSQL Connection

    + +
    + + + + + +
    + +
    + +
    + + + + ) : ( +
    +

    No database configured

    +

    + Database will be automatically provisioned when sandbox is created +

    -
    - + )} + ); -} +} \ No newline at end of file diff --git a/app/(dashboard)/projects/[id]/environment/page.tsx b/app/(dashboard)/projects/[id]/environment/page.tsx index acc8284..2dd5515 100644 --- a/app/(dashboard)/projects/[id]/environment/page.tsx +++ b/app/(dashboard)/projects/[id]/environment/page.tsx @@ -1,14 +1,12 @@ /** * Environment Variables Configuration Page * Configure custom environment variables - * VSCode Dark Modern style */ 'use client'; import { useParams } from 'next/navigation'; -import { ConfigLayout } from '@/components/config/config-layout'; import { EnvVarSection } from '@/components/config/env-var-section'; import { useBatchUpdateEnvironmentVariables, @@ -16,6 +14,8 @@ import { } from '@/hooks/use-environment-variables'; import { useProject } from '@/hooks/use-project'; +import { SettingsLayout } from '../_components/settings-layout'; + function EnvironmentPageContent() { const params = useParams(); const projectId = params.id as string; @@ -36,7 +36,7 @@ function EnvironmentPageContent() { }; return ( - {/* Usage Information */} -
    -

    Environment Variable Usage

    -
      +
      +

      Environment Variable Usage

      +
      • Environment variables are available in your application via process.env
      • Changes require an application restart to take effect
      • For authentication providers, use the Authentication page
      • @@ -65,7 +65,7 @@ function EnvironmentPageContent() {
    -
    + ); } diff --git a/app/(dashboard)/projects/[id]/github/page.tsx b/app/(dashboard)/projects/[id]/github/page.tsx index d3fb898..25b7de2 100644 --- a/app/(dashboard)/projects/[id]/github/page.tsx +++ b/app/(dashboard)/projects/[id]/github/page.tsx @@ -5,12 +5,13 @@ import { ExternalLink, Github, Loader2, RefreshCw } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import { toast } from 'sonner'; -import { ConfigLayout } from '@/components/config/config-layout'; import SettingsDialog from '@/components/dialog/settings-dialog'; import { Button } from '@/components/ui/button'; import { useProject } from '@/hooks/use-project'; import { commitChanges, initializeRepo } from '@/lib/services/repoService'; +import { SettingsLayout } from '../_components/settings-layout'; + export default function GithubPage() { const params = useParams(); const projectId = params.id as string; @@ -72,14 +73,14 @@ export default function GithubPage() { }; return ( -
    {/* Connection Status Section */} -
    +
    {/* Visual Header */}
    @@ -179,6 +180,6 @@ export default function GithubPage() { defaultTab="github" />
    - + ); } diff --git a/app/(dashboard)/projects/[id]/layout.tsx b/app/(dashboard)/projects/[id]/layout.tsx index 5514edd..855bbd7 100644 --- a/app/(dashboard)/projects/[id]/layout.tsx +++ b/app/(dashboard)/projects/[id]/layout.tsx @@ -2,9 +2,8 @@ import { redirect } from 'next/navigation'; import { notFound } from 'next/navigation'; import { ProjectContentWrapper } from '@/components/layout/project-content-wrapper'; -import { StatusBar } from '@/components/layout/status-bar'; import PrimarySidebar from '@/components/sidebars/primary-sidebar'; -import ProjectSidebar from '@/components/sidebars/project-sidebar-new'; +import ProjectSidebar from '@/components/sidebars/project-sidebar'; import { auth } from '@/lib/auth'; import { prisma } from '@/lib/db'; @@ -44,7 +43,7 @@ export default async function ProjectLayout({
    {/* Primary Sidebar - VSCode style */} - + {/* Secondary Sidebar - Project Settings */}
    - -
    ); } diff --git a/app/(dashboard)/projects/[id]/payment/page.tsx b/app/(dashboard)/projects/[id]/payment/page.tsx index 723f54d..381dad9 100644 --- a/app/(dashboard)/projects/[id]/payment/page.tsx +++ b/app/(dashboard)/projects/[id]/payment/page.tsx @@ -1,7 +1,6 @@ /** * Payment Configuration Page * Configure payment providers (Stripe, PayPal) - * VSCode Dark Modern style */ 'use client'; @@ -9,7 +8,6 @@ import { ExternalLink } from 'lucide-react'; import { useParams } from 'next/navigation'; -import { ConfigLayout } from '@/components/config/config-layout'; import { EnvVarSection } from '@/components/config/env-var-section'; import { useBatchUpdateEnvironmentVariables, @@ -17,6 +15,8 @@ import { } from '@/hooks/use-environment-variables'; import { useProject } from '@/hooks/use-project'; +import { SettingsLayout } from '../_components/settings-layout'; + function PaymentPageContent() { const params = useParams(); const projectId = params.id as string; @@ -80,7 +80,7 @@ function PaymentPageContent() { ]; return ( -
    Stripe -

    Stripe

    +

    Stripe

    Stripe Dashboard @@ -114,9 +114,9 @@ function PaymentPageContent() { /> {/* Setup Instructions */} -
    -

    Setup Instructions

    -
      +
      +

      Setup Instructions

      +
      1. Go to Stripe Dashboard → Developers → API keys
      2. Copy the Publishable key and Secret key
      3. For webhooks: Developers → Webhooks → Add endpoint
      4. @@ -126,20 +126,20 @@ function PaymentPageContent() {
      {/* Divider */} -
      +
      {/* PayPal Section */}
      - + ); } diff --git a/app/(dashboard)/projects/[id]/secrets/page.tsx b/app/(dashboard)/projects/[id]/secrets/page.tsx index b769a6f..e1ee3b4 100644 --- a/app/(dashboard)/projects/[id]/secrets/page.tsx +++ b/app/(dashboard)/projects/[id]/secrets/page.tsx @@ -1,14 +1,12 @@ /** * Secrets Configuration Page * Manage sensitive environment variables and API keys - * VSCode Dark Modern style */ 'use client'; import { useParams } from 'next/navigation'; -import { ConfigLayout } from '@/components/config/config-layout'; import { EnvVarSection } from '@/components/config/env-var-section'; import { useBatchUpdateEnvironmentVariables, @@ -16,6 +14,8 @@ import { } from '@/hooks/use-environment-variables'; import { useProject } from '@/hooks/use-project'; +import { SettingsLayout } from '../_components/settings-layout'; + function SecretsPageContent() { const params = useParams(); const projectId = params.id as string; @@ -36,7 +36,7 @@ function SecretsPageContent() { }; return ( - {/* Security Notice */} -
      -

      Security Best Practices

      -
        +
        +

        Security Best Practices

        +
        • All secret values are masked by default for security
        • Never commit secrets to Git
        • Rotate secrets regularly to maintain security
        • @@ -65,7 +65,7 @@ function SecretsPageContent() {
      -
      + ); } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index a43e31e..48f677e 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -287,6 +287,8 @@ export const POST = withAuth(async (req, _context, session) fileBrowserUsernameEnv, fileBrowserPasswordEnv, } + }, { + timeout: 20000, }) logger.info( diff --git a/app/globals.css b/app/globals.css index 1efedca..43862e5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -173,6 +173,138 @@ --tab-hover-background: #3e3e42; } +/* Modern Theme - Dark Mode (default) */ +.modern { + /* === Base Colors === */ + --background: #09090b; + --foreground: #fafafa; + + /* === UI Components === */ + --card: #18181b; + --card-foreground: #fafafa; + --popover: #18181b; + --popover-foreground: #fafafa; + + /* === Brand Colors === */ + --primary: #3b82f6; + --primary-foreground: #ffffff; + --primary-hover: #60a5fa; + + --secondary: #27272a; + --secondary-foreground: #a1a1aa; + + --accent: #27272a; + --accent-foreground: #fafafa; + + --muted: #27272a; + --muted-foreground: #a1a1aa; + + --destructive: #ef4444; + --destructive-foreground: #ffffff; + + /* === Borders & Inputs === */ + --border: #27272a; + --input: #27272a; + --ring: #3b82f6; + + /* === Sidebar === */ + --sidebar: #18181b; + --sidebar-foreground: #fafafa; + --sidebar-background: #18181b; + --sidebar-project-background: #09090b; + --sidebar-accent: #27272a; + --sidebar-border: #27272a; + + /* === Charts === */ + --chart-1: #3b82f6; + --chart-2: #94a3b8; + --chart-3: #34d399; + --chart-4: #fbbf24; + --chart-5: #f87171; + + /* === Content === */ + --content-background: #09090b; + + /* === Tabs === */ + --tabs-background: #18181b; + --tab-background: #18181b; + --tab-foreground: #a1a1aa; + --tab-active-background: #09090b; + --tab-active-foreground: #ffffff; + --tab-hover-background: #27272a; + + /* === Typography === */ + --font-sans: Inter, sans-serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; +} + +/* Modern Theme - Light Mode (optional) */ +.modern-light { + /* === Base Colors === */ + --background: #ffffff; + --foreground: #09090b; + + /* === UI Components === */ + --card: #f9fafb; + --card-foreground: #09090b; + --popover: #ffffff; + --popover-foreground: #09090b; + + /* === Brand Colors === */ + --primary: #3b82f6; + --primary-foreground: #ffffff; + --primary-hover: #2563eb; + + --secondary: #f3f4f6; + --secondary-foreground: #64748b; + + --accent: #f3f4f6; + --accent-foreground: #09090b; + + --muted: #f3f4f6; + --muted-foreground: #64748b; + + --destructive: #ef4444; + --destructive-foreground: #ffffff; + + /* === Borders & Inputs === */ + --border: #e5e7eb; + --input: #e5e7eb; + --ring: #3b82f6; + + /* === Sidebar === */ + --sidebar: #f3f4f6; + --sidebar-foreground: #09090b; + --sidebar-background: #f3f4f6; + --sidebar-project-background: #ffffff; + --sidebar-accent: #e5e7eb; + --sidebar-border: #e5e7eb; + + /* === Charts === */ + --chart-1: #3b82f6; + --chart-2: #64748b; + --chart-3: #10b981; + --chart-4: #f59e0b; + --chart-5: #ef4444; + + /* === Content === */ + --content-background: #ffffff; + + /* === Tabs === */ + --tabs-background: #f3f4f6; + --tab-background: #f3f4f6; + --tab-foreground: #64748b; + --tab-active-background: #ffffff; + --tab-active-foreground: #09090b; + --tab-hover-background: #e5e7eb; + + /* === Typography === */ + --font-sans: Inter, sans-serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/components/config/config-layout.tsx b/components/config/config-layout.tsx deleted file mode 100644 index 6e45792..0000000 --- a/components/config/config-layout.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Shared configuration page layout - * VSCode Dark Modern style with clean line-based design - */ - -'use client'; - -import type { ReactNode } from 'react'; - -import { Spinner } from '@/components/ui/spinner'; - - -interface ConfigLayoutProps { - /** Page title */ - title: string; - /** Page description */ - description: string; - /** Main content */ - children: ReactNode; - /** Loading state */ - loading?: boolean; -} - -/** - * Configuration page layout with VSCode Dark Modern styling - */ -export function ConfigLayout({ title, description, children, loading }: ConfigLayoutProps) { - if (loading) { - return ( -
      -
      - - Loading configuration... -
      -
      - ); - } - - return ( -
      -
      - {/* Header - Clean VSCode style */} -
      -
      -

      {title}

      -

      {description}

      -
      -
      - - {/* Content - Scrollable area */} -
      - {children} -
      -
      -
      - - ); -} diff --git a/components/layout/project-content-wrapper.module.css b/components/layout/project-content-wrapper.module.css index 95f0aa6..d5e749b 100644 --- a/components/layout/project-content-wrapper.module.css +++ b/components/layout/project-content-wrapper.module.css @@ -22,9 +22,10 @@ content-visibility: hidden; /* Modern CSS optimization */ } -/* Terminal panel - block layout */ +/* Terminal panel - flex column layout for terminal + status bar */ .terminalPanel[data-visible='true'] { - display: block; + display: flex; + flex-direction: column; content-visibility: auto; } diff --git a/components/layout/project-content-wrapper.tsx b/components/layout/project-content-wrapper.tsx index 29a4a59..52019d6 100644 --- a/components/layout/project-content-wrapper.tsx +++ b/components/layout/project-content-wrapper.tsx @@ -23,6 +23,8 @@ import { usePathname } from 'next/navigation'; import { TerminalContainer } from '@/components/terminal/terminal-container'; import { useProject } from '@/hooks/use-project'; +import { StatusBar } from './status-bar'; + import styles from './project-content-wrapper.module.css'; // ============================================================================ @@ -63,11 +65,14 @@ export function ProjectContentWrapper({ aria-label="Terminal Console" > {project && ( - + <> + + + )}
      diff --git a/components/layout/repo-status-indicator.tsx b/components/layout/repo-status-indicator.tsx index d751713..1e5b814 100644 --- a/components/layout/repo-status-indicator.tsx +++ b/components/layout/repo-status-indicator.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Project } from '@prisma/client' -import { Github, Loader2, RefreshCw } from 'lucide-react' +import { Loader2, RefreshCw } from 'lucide-react' import { useRouter } from 'next/navigation' import { toast } from 'sonner' @@ -77,9 +77,9 @@ export function RepoStatusIndicator({ project }: RepoStatusIndicatorProps) {
      {(!project.githubRepo && isInitializing) ? ( - + ) : ( - + )}
      @@ -88,13 +88,13 @@ export function RepoStatusIndicator({ project }: RepoStatusIndicatorProps) { href={project.githubRepo} target="_blank" rel="noopener noreferrer" - className="hover:underline cursor-pointer text-sm" + className="hover:underline cursor-pointer" > {project.name}
      ) : ( - -
      - - {/* Project List */} -
      - {projects?.map((project) => ( - -
      - - -
      - - ))} -
      + {/* Bottom buttons */} +
      +
      + + ); +} - {/* Settings */} -
      - -
      +function HomeButton() { + return ( + + ); +} + +function ProjectsButton() { + return ( + + ); +} - {/* Create Project Dialog */} - +function SettingsButton() { + const [showSettings, setShowSettings] = useState(false); - {/* Settings Dialog */} + return ( + <> + -
      + ); } diff --git a/components/sidebars/project-sidebar-new.tsx b/components/sidebars/project-sidebar-new.tsx deleted file mode 100644 index 25652ee..0000000 --- a/components/sidebars/project-sidebar-new.tsx +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { - ChevronLeft, - ChevronRight, - CreditCard, - Database, - Github, - Key, - Package, - Shield, - Terminal, -} from 'lucide-react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { useProject } from '@/hooks/use-project'; -import { cn } from '@/lib/utils'; - -interface ProjectSidebarProps { - projectId: string; -} - -export default function ProjectSidebar({ projectId }: ProjectSidebarProps) { - const { data: project } = useProject(projectId); - const [isCollapsed, setIsCollapsed] = useState(false); - const pathname = usePathname(); - - const topSections = [ - { - id: 'terminal', - label: 'Web Terminal', - icon: Terminal, - href: `/projects/${projectId}/terminal`, - }, - { id: 'database', label: 'Database', icon: Database, href: `/projects/${projectId}/database` }, - ]; - - const configSections = [ - { - id: 'environment', - label: 'Environment Variables', - icon: Package, - href: `/projects/${projectId}/environment`, - }, - { - id: 'secrets', - label: 'Secret Configuration', - icon: Key, - href: `/projects/${projectId}/secrets`, - }, - { id: 'auth', label: 'Auth Configuration', icon: Shield, href: `/projects/${projectId}/auth` }, - { - id: 'payment', - label: 'Payment Configuration', - icon: CreditCard, - href: `/projects/${projectId}/payment`, - }, - { - id: 'github', - label: 'GitHub Integration', - icon: Github, - href: `/projects/${projectId}/github`, - }, - ]; - - return ( -
      - {/* Toggle Button */} - - - {/* Header */} -
      - {!isCollapsed && ( - Project {project?.name ?? 'Loading...'} - )} -
      - - {!isCollapsed && ( -
      - {/* Top sections */} -
      -
      WORKSPACE
      - {topSections.map((section) => { - const Icon = section.icon; - const isActive = pathname === section.href; - - return ( - - - {section.label} - - ); - })} -
      - - {/* Configuration Group */} -
      -
      CONFIGURATION
      - {configSections.map((section) => { - const Icon = section.icon; - const isActive = pathname === section.href; - - return ( - - - {section.label} - - ); - })} -
      -
      - )} -
      - ); -} diff --git a/components/sidebars/project-sidebar.tsx b/components/sidebars/project-sidebar.tsx new file mode 100644 index 0000000..0a25896 --- /dev/null +++ b/components/sidebars/project-sidebar.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; +import { IconType } from 'react-icons'; +import { + MdOutlineCode, + MdOutlineCreditCard, + MdOutlineDns, + MdOutlineLayers, + MdOutlineSecurity, + MdOutlineTerminal, + MdOutlineVpnKey, +} from 'react-icons/md'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { useProject } from '@/hooks/use-project'; +import { cn } from '@/lib/utils'; + +// Icon mapping for dynamic icon rendering +const ICON_MAP: Record = { + terminal: MdOutlineTerminal, + dns: MdOutlineDns, + layers: MdOutlineLayers, + vpn_key: MdOutlineVpnKey, + security: MdOutlineSecurity, + credit_card: MdOutlineCreditCard, + code: MdOutlineCode, +}; + +// Static menu configuration - hoisted outside component to avoid recreation on every render +const WORKSPACE_SECTIONS = [ + { id: 'terminal', label: 'Web Terminal', icon: 'terminal' }, + { id: 'database', label: 'Database', icon: 'dns' }, +] as const; + +const CONFIG_SECTIONS = [ + { id: 'environment', label: 'Environment Variables', icon: 'layers' }, + { id: 'secrets', label: 'Secret Configuration', icon: 'vpn_key' }, + { id: 'auth', label: 'Auth Configuration', icon: 'security' }, + { id: 'payment', label: 'Payment Configuration', icon: 'credit_card' }, + { id: 'github', label: 'GitHub Integration', icon: 'code' }, +] as const; + +interface ProjectSidebarProps { + projectId: string; +} + +/** + * A collapsible navigation sidebar for project detail pages. + * + * Features: + * - Displays current project name in header + * - Provides navigation links grouped into Workspace and Configuration sections + * - Supports collapse/expand toggle with smooth transition + * - Highlights active route based on current pathname + * + * Navigation Structure: + * - Workspace: Web Terminal, Database + * - Configuration: Environment Variables, Secrets, Auth, Payment, GitHub Integration + */ +export default function ProjectSidebar({ projectId }: ProjectSidebarProps) { + const { data: project } = useProject(projectId); + const [isCollapsed, setIsCollapsed] = useState(false); + const pathname = usePathname(); + + // Generate href with projectId + const getHref = (sectionId: string) => `/projects/${projectId}/${sectionId}`; + + return ( +
      + {/* Toggle Button */} + + + {/* Header */} +
      + {!isCollapsed && ( + Project {project?.name ?? 'Loading...'} + )} +
      + + {!isCollapsed && ( +
      + {/* Top sections */} +
      +
      Workspace
      +
        + {WORKSPACE_SECTIONS.map((section) => { + const href = getHref(section.id); + const isActive = pathname === href; + const IconComponent = ICON_MAP[section.icon]; + + return ( +
      • + + + {section.label} + +
      • + ); + })} +
      +
      + + {/* Configuration Group */} +
      +
      Configuration
      +
        + {CONFIG_SECTIONS.map((section) => { + const href = getHref(section.id); + const isActive = pathname === href; + const IconComponent = ICON_MAP[section.icon]; + + return ( +
      • + + + {section.label} + +
      • + ); + })} +
      +
      +
      + )} +
      + ); +} diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index e6884c5..f8fd1eb 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -16,7 +16,8 @@ import { useState } from 'react'; import type { Prisma } from '@prisma/client'; -import { type Tab, TerminalToolbar } from './toolbar/toolbar'; +import { type Tab } from './toolbar/terminal-tabs'; +import { TerminalToolbar } from './toolbar/toolbar'; import { TerminalDisplay } from './terminal-display'; // ============================================================================ @@ -118,7 +119,7 @@ export function TerminalContainer({ project, sandbox, isVisible = true }: Termin // ========================================================================= return ( -
      +
      {/* Toolbar with tabs and operations */} {/* Terminal display area with tab switching */} -
      +
      {tabs.map((tab) => (
      +
      {/* Loading Overlay */} {isLoading && ( -
      +
      - - + + {!terminalReady ? 'Initializing terminal...' : 'Establishing connection...'}
      @@ -127,25 +127,25 @@ export function TerminalDisplay({ wsUrl={ttydUrl} sandboxId={sandboxId} theme={{ - foreground: '#d2d2d2', - background: '#1e1e1e', - cursor: '#adadad', - black: '#000000', - red: '#d81e00', - green: '#5ea702', - yellow: '#cfae00', - blue: '#427ab3', - magenta: '#89658e', - cyan: '#00a7aa', - white: '#dbded8', - brightBlack: '#686a66', - brightRed: '#f54235', - brightGreen: '#99e343', - brightYellow: '#fdeb61', - brightBlue: '#84b0d8', - brightMagenta: '#bc94b7', - brightCyan: '#37e6e8', - brightWhite: '#f1f1f0', + foreground: '#fafafa', + background: '#09090b', + cursor: '#fafafa', + black: '#09090b', + red: '#ef4444', + green: '#22c55e', + yellow: '#eab308', + blue: '#3b82f6', + magenta: '#a855f7', + cyan: '#06b6d4', + white: '#fafafa', + brightBlack: '#71717a', + brightRed: '#f87171', + brightGreen: '#4ade80', + brightYellow: '#facc15', + brightBlue: '#60a5fa', + brightMagenta: '#c084fc', + brightCyan: '#22d3ee', + brightWhite: '#ffffff', }} fontSize={14} fontFamily="Consolas, Liberation Mono, Menlo, Courier, monospace" @@ -191,10 +191,10 @@ export function TerminalDisplay({ : TerminalIcon; return ( -
      +
      - {getStatusMessage(status)} + {getStatusMessage(status)}
      ); diff --git a/components/terminal/toolbar/app-runner.tsx b/components/terminal/toolbar/app-runner.tsx index 5d06726..4a327b2 100644 --- a/components/terminal/toolbar/app-runner.tsx +++ b/components/terminal/toolbar/app-runner.tsx @@ -61,7 +61,7 @@ export function AppRunner({ sandbox }: AppRunnerProps) { 'px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 disabled:cursor-not-allowed', isAppRunning ? 'text-green-400 hover:text-red-400 hover:bg-red-400/10 bg-green-400/10' - : 'text-gray-300 hover:text-white hover:bg-[#37373d] disabled:opacity-50' + : 'text-foreground font-semibold hover:text-white hover:bg-zinc-800 disabled:opacity-50' )} title={ isAppRunning @@ -74,7 +74,7 @@ export function AppRunner({ sandbox }: AppRunnerProps) { ) : isAppRunning ? ( ) : ( - + )} {isStartingApp ? 'Starting...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Running' : 'Run App'} diff --git a/components/terminal/toolbar/network-dialog.tsx b/components/terminal/toolbar/network-dialog.tsx new file mode 100644 index 0000000..77804da --- /dev/null +++ b/components/terminal/toolbar/network-dialog.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useState } from 'react'; +import { Copy, Eye, EyeOff } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface NetworkEndpoint { + domain: string | null | undefined; + port: number; + protocol: string; + label: string; + hasCredentials?: boolean; +} + +export interface NetworkDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + endpoints: NetworkEndpoint[]; + fileBrowserCredentials?: { + username: string; + password: string; + }; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function NetworkDialog({ + open, + onOpenChange, + endpoints, + fileBrowserCredentials, +}: NetworkDialogProps) { + const [showPassword, setShowPassword] = useState(false); + const [copiedField, setCopiedField] = useState(null); + + const copyToClipboard = async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + + + Network Endpoints + + All publicly accessible endpoints for this sandbox + + +
      + {endpoints.map((endpoint, index) => ( +
      +
      +
      + Port {endpoint.port} + + {endpoint.label} + +
      + {endpoint.protocol} +
      + + {endpoint.domain} + + + {/* Show credentials for File Browser */} + {endpoint.hasCredentials && fileBrowserCredentials && ( +
      +
      Login Credentials:
      + + {/* Username */} +
      +
      +
      Username
      + + {fileBrowserCredentials.username} + +
      + +
      + + {/* Password */} +
      +
      +
      Password
      + + {showPassword ? fileBrowserCredentials.password : '••••••••••••••••'} + +
      + + +
      +
      + )} +
      + ))} +
      +
      +
      + ); +} diff --git a/components/terminal/toolbar/terminal-tabs.tsx b/components/terminal/toolbar/terminal-tabs.tsx new file mode 100644 index 0000000..2f786c8 --- /dev/null +++ b/components/terminal/toolbar/terminal-tabs.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { Plus, Terminal as TerminalIcon, X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface Tab { + id: string; + name: string; +} + +export interface TerminalTabsProps { + tabs: Tab[]; + activeTabId: string; + onTabSelect: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onTabAdd: () => void; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function TerminalTabs({ + tabs, + activeTabId, + onTabSelect, + onTabClose, + onTabAdd, +}: TerminalTabsProps) { + return ( +
      + {tabs.map((tab) => ( +
      onTabSelect(tab.id)} + > + {/* Top Accent Line for Active Tab */} + {activeTabId === tab.id && ( +
      + )} + + + {tab.name} + {tabs.length > 1 && ( + + )} +
      + ))} + +
      + ); +} diff --git a/components/terminal/toolbar/toolbar.tsx b/components/terminal/toolbar/toolbar.tsx index 23523fb..918bccd 100644 --- a/components/terminal/toolbar/toolbar.tsx +++ b/components/terminal/toolbar/toolbar.tsx @@ -6,20 +6,13 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import type { Prisma } from '@prisma/client'; -import { Copy, Eye, EyeOff, Network, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { cn } from '@/lib/utils'; +import { Network } from 'lucide-react'; import { AppRunner } from './app-runner'; +import { NetworkDialog } from './network-dialog'; +import { type Tab,TerminalTabs } from './terminal-tabs'; type Project = Prisma.ProjectGetPayload<{ include: { @@ -30,11 +23,6 @@ type Project = Prisma.ProjectGetPayload<{ type Sandbox = Prisma.SandboxGetPayload; -export interface Tab { - id: string; - name: string; -} - export interface TerminalToolbarProps { /** Project data */ project: Project; @@ -70,8 +58,6 @@ export function TerminalToolbar({ fileBrowserCredentials, }: TerminalToolbarProps) { const [showNetworkDialog, setShowNetworkDialog] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [copiedField, setCopiedField] = useState(null); // Build network endpoints list, filtering out any without URLs const allEndpoints = [ @@ -89,63 +75,17 @@ export function TerminalToolbar({ // Only show endpoints that have a valid domain URL const networkEndpoints = allEndpoints.filter((endpoint) => endpoint.domain); - const copyToClipboard = async (text: string, field: string) => { - try { - await navigator.clipboard.writeText(text); - setCopiedField(field); - setTimeout(() => setCopiedField(null), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - return ( <> -
      +
      {/* Terminal Tabs */} -
      - {tabs.map((tab) => ( -
      onTabSelect(tab.id)} - > - {/* Top Accent Line for Active Tab */} - {activeTabId === tab.id && ( -
      - )} - - - {tab.name} - {tabs.length > 1 && ( - - )} -
      - ))} - -
      + {/* Action Buttons */}
      @@ -154,112 +94,22 @@ export function TerminalToolbar({ {/* Network Button */}
      {/* Network Dialog */} - - - - Network Endpoints - - All publicly accessible endpoints for this sandbox - - -
      - {networkEndpoints.map((endpoint, index) => ( -
      -
      -
      - Port {endpoint.port} - - {endpoint.label} - -
      - {endpoint.protocol} -
      - - {endpoint.domain} - - - {/* Show credentials for File Browser */} - {endpoint.hasCredentials && fileBrowserCredentials && ( -
      -
      Login Credentials:
      - - {/* Username */} -
      -
      -
      Username
      - - {fileBrowserCredentials.username} - -
      - -
      - - {/* Password */} -
      -
      -
      Password
      - - {showPassword ? fileBrowserCredentials.password : '••••••••••••••••'} - -
      - - -
      -
      - )} -
      - ))} -
      -
      -
      + ); } diff --git a/components/ui/button.tsx b/components/ui/button.tsx index ca0c4c8..37a7d4b 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -9,15 +9,15 @@ const buttonVariants = cva( { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary-hover", + default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - "bg-secondary text-secondary-foreground border border-border hover:bg-border", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "text-muted-foreground hover:bg-border hover:text-foreground", + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { @@ -38,8 +38,8 @@ const buttonVariants = cva( function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & @@ -51,6 +51,8 @@ function Button({ return ( diff --git a/lib/data/database.ts b/lib/data/database.ts new file mode 100644 index 0000000..b4fe53f --- /dev/null +++ b/lib/data/database.ts @@ -0,0 +1,19 @@ +export interface ConnectionInfo { + host: string; + port: string; + database: string; + username: string; + password: string; +} + +export function parseConnectionUrl(connectionString: string): ConnectionInfo | null { + try { + const match = connectionString.match(/postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/); + if (!match) return null; + + const [, username, password, host, port, database] = match; + return { host, port, database, username, password }; + } catch { + return null; + } +} diff --git a/lib/data/project.ts b/lib/data/project.ts new file mode 100644 index 0000000..44055f1 --- /dev/null +++ b/lib/data/project.ts @@ -0,0 +1,55 @@ +import { cache } from 'react'; +import type { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; +import { logger } from '@/lib/logger'; + +/** + * Project relation include options + */ +export type ProjectInclude = { + sandboxes?: boolean; + databases?: boolean; + environments?: boolean; +}; + +/** + * Project with relations based on include options + */ +export type ProjectWithRelations = + Prisma.ProjectGetPayload<{ include: T }>; + +/** + * Get a project by ID and user ID + * Uses React.cache() to deduplicate calls within the same request + * @param include - Optional relation loading (default: all false) + */ +export const getProject = cache(async function getProject( + projectId: string, + userId: string, + include?: ProjectInclude +): Promise { + const shouldInclude: Required = { + sandboxes: false, + databases: false, + environments: false, + ...include, + }; + + try { + const project = await prisma.project.findFirst({ + where: { id: projectId, userId }, + include: shouldInclude, + }); + + if (!project) { + logger.warn(`Project ${projectId} not found for user ${userId}`); + return null; + } + + return project; + } catch (error) { + logger.error(`Failed to fetch project ${projectId}: ${error}`); + throw error; + } +}); diff --git a/lib/k8s/kubernetes-utils.ts b/lib/k8s/kubernetes-utils.ts index e1a0fbe..eca918f 100644 --- a/lib/k8s/kubernetes-utils.ts +++ b/lib/k8s/kubernetes-utils.ts @@ -42,6 +42,11 @@ export class KubernetesUtils { const url = new URL(cluster.server) const hostname = url.hostname + // If domain ends with sealos.io, replace it with sealos.app + if (hostname.endsWith('sealos.io')) { + return hostname.replace('sealos.io', 'sealos.app') + } + return hostname } diff --git a/package.json b/package.json index 358918a..6b32522 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "prisma": "^6.17.1", "react": "19.2.1", "react-dom": "19.2.1", + "react-icons": "^5.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tus-js-client": "^4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b1037e..bc381c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: react-dom: specifier: 19.2.1 version: 19.2.1(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -2802,6 +2805,11 @@ packages: peerDependencies: react: ^19.2.1 + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5958,6 +5966,10 @@ snapshots: react: 19.2.1 scheduler: 0.27.0 + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + react-is@16.13.1: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.1): diff --git a/provider/providers.tsx b/provider/providers.tsx index 84d1818..1ea3e2b 100644 --- a/provider/providers.tsx +++ b/provider/providers.tsx @@ -28,9 +28,9 @@ export function Providers({ children }: { children: React.ReactNode }) { {children}