diff --git a/components/console/repository-list.tsx b/components/console/repository-list.tsx index 9cdf6aa..34d5b3f 100644 --- a/components/console/repository-list.tsx +++ b/components/console/repository-list.tsx @@ -1,98 +1,209 @@ "use client"; -import { useEffect, useState } from "react"; + +import { useEffect, useState, useMemo, useCallback } from "react"; import { useSession } from "next-auth/react"; -import { Github, AlertCircle, PackageOpen, LayoutGrid, List } from "lucide-react"; import { signIn } from "next-auth/react"; +import { + Github, + AlertCircle, + PackageOpen, + LayoutGrid, + List, + Search, + ArrowUpDown, + UploadCloud, + FileCode, + Trash2, + CheckCircle2 +} from "lucide-react"; import { RepositoryCard, type GitHubRepo } from "./repository-card"; import { RepositoryTable } from "./repository-table"; +import { useDebounce } from "@/lib/hooks/useDebounce"; + +// ============================================================================ +// TYPE DEFINITIONS & INTERFACES +// ============================================================================ + +/** + * Represents a file that has been successfully validated and staged + * in the Drag-and-Drop zone, ready for payload injection. + */ +export interface InjectedFile { + id: string; + name: string; + size: number; + type: string; + buffer: ArrayBuffer; +} + +export type SortOption = "updated" | "name" | "stars"; + +interface RepositoryListProps { + limit?: number; + showViewToggle?: boolean; + defaultView?: "grid" | "table"; + compact?: boolean; +} -function SkeletonCard() { +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Formats raw byte sizes into human-readable strings (KB, MB). + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +// ============================================================================ +// SUB-COMPONENTS (Decoupled for clean render cycles) +// ============================================================================ + +function SkeletonGrid() { return ( -
-
-
-
-
-
-
-
-
-
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))}
); } -function SkeletonRow() { +function SkeletonTable() { return ( - - -
-
- - -
- - -
- - -
- - -
-
-
-
- - +
+ + + + {["Repository", "Visibility", "Language", "Updated", "Actions"].map((h) => ( + + ))} + + + + {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + ))} + +
+ {h} +
+
+
+
+
+
+
+
+
+
); } -interface RepositoryListProps { - /** Limit how many repos to display. Defaults to all. */ - limit?: number; - /** Show view-toggle (grid/table). Defaults true. */ - showViewToggle?: boolean; - /** Initial view layout. */ - defaultView?: "grid" | "table"; - /** Compact mode hides header controls, for embedding in dashboard. */ - compact?: boolean; +function EmptyState({ search }: { search: string }) { + return ( +
+
+ +
+

+ {search ? "No repositories match your search" : "No repositories found"} +

+

+ {search + ? `We couldn't find anything matching "${search}". Try adjusting your filters.` + : "Create a new repository on your GitHub account to get started with deployments."} +

+
+ ); } -export function RepositoryList({ - limit, - showViewToggle = true, - defaultView = "table", - compact = false, +// ============================================================================ +// MAIN COMPONENT EXPORT +// ============================================================================ + +export function RepositoryList({ + limit, + showViewToggle = true, + defaultView = "table", + compact = false }: RepositoryListProps) { + + // Auth & API States const { data: session, status } = useSession(); const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + + // UI Controls const [view, setView] = useState<"grid" | "table">(defaultView); const [deployingId, setDeployingId] = useState(null); const [deployMsg, setDeployMsg] = useState<{ id: number; ok: boolean; msg: string; url?: string } | null>(null); + + // Filter & Sort Maps const [search, setSearch] = useState(""); + const [sortBy, setSortBy] = useState("updated"); + const debouncedSearch = useDebounce(search, 300); + + // Drag & Drop Injection States + const [isDragActive, setIsDragActive] = useState(false); + const [injectedFiles, setInjectedFiles] = useState([]); + const [fileError, setFileError] = useState(null); + // -------------------------------------------------------------------------- + // NETWORK INITIATION HOOK + // -------------------------------------------------------------------------- useEffect(() => { if (status !== "authenticated" || !session?.accessToken) return; - + + let isMounted = true; setLoading(true); setError(null); - + fetch("/api/github/repos") .then((r) => r.json()) .then((data) => { + if (!isMounted) return; if (Array.isArray(data)) { setRepos(data); } else { - setError(data?.error ?? "Failed to load repositories"); + setError(data?.error ?? "Failed to load remote repositories. Check GitHub connection."); } }) - .catch(() => setError("Network error — could not reach GitHub API")) - .finally(() => setLoading(false)); + .catch(() => { + if (isMounted) setError("Network exception — could not resolve GitHub API endpoint."); + }) + .finally(() => { + if (isMounted) setLoading(false); + }); + + return () => { isMounted = false; }; }, [status, session?.accessToken]); + // -------------------------------------------------------------------------- + // DEPLOYMENT PIPELINE + // -------------------------------------------------------------------------- const handleDeploy = async (repo: GitHubRepo) => { setDeployingId(repo.id); setDeployMsg(null); @@ -104,31 +215,126 @@ export function RepositoryList({ repo_name: repo.name, repo_url: repo.clone_url, branch: repo.default_branch, + injected_files: injectedFiles.map(f => ({ name: f.name, size: f.size })) }), }); const data = await res.json(); if (res.ok && data.ok) { - const url = data.deployment?.publicUrl; - setDeployMsg({ - id: repo.id, - ok: true, - msg: `Deployment started for ${repo.name}${url ? ` — ` : ""}`, - url, + setDeployMsg({ + id: repo.id, + ok: true, + msg: `Pipeline initialized for ${repo.name}`, + url: data.deployment?.publicUrl }); + setInjectedFiles([]); // Clear staged files on successful dispatch } else { - setDeployMsg({ id: repo.id, ok: false, msg: data.error ?? "Deployment failed" }); + setDeployMsg({ id: repo.id, ok: false, msg: data.error ?? "Pipeline execution failed." }); } } catch { - setDeployMsg({ id: repo.id, ok: false, msg: "Network error during deploy" }); + setDeployMsg({ id: repo.id, ok: false, msg: "Network error during deployment handshake." }); } finally { setDeployingId(null); setTimeout(() => setDeployMsg(null), 8000); } }; - // Not signed in with GitHub + // -------------------------------------------------------------------------- + // NATIVE DRAG AND DROP HANDLERS + // -------------------------------------------------------------------------- + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragActive(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragActive(false); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent | any) => { + e.preventDefault(); + setIsDragActive(false); + setFileError(null); + + const files = Array.from(e.dataTransfer?.files || e.target?.files || []) as File[]; + const validFiles: File[] = []; + + // Validation Checkpoint + for (const file of files) { + if (file.size > 2 * 1024 * 1024) { + setFileError(`Security constraint: "${file.name}" exceeds the 2MB memory limit.`); + return; + } + validFiles.push(file); + } + + // Process files asynchronously using FileReader bounds + const processedFiles = await Promise.all( + validFiles.map((file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + resolve({ + id: crypto.randomUUID(), + name: file.name, + size: file.size, + type: file.type || "unknown", + buffer: event.target?.result as ArrayBuffer, + }); + }; + reader.onerror = () => reject(new Error("File stream processing failure")); + reader.readAsArrayBuffer(file); + }); + }) + ); + + // Merge new files while preventing duplicate names + setInjectedFiles((prev) => { + const existingNames = new Set(prev.map(f => f.name)); + const filteredNew = processedFiles.filter(f => !existingNames.has(f.name)); + return [...prev, ...filteredNew]; + }); + }, []); + + const removeFile = (id: string) => { + setInjectedFiles((prev) => prev.filter((f) => f.id !== id)); + }; + + // -------------------------------------------------------------------------- + // MEMOIZED SORT & FILTER ENGINES + // -------------------------------------------------------------------------- + const filteredRepos = useMemo(() => { + if (!debouncedSearch) return repos; + const lowerSearch = debouncedSearch.toLowerCase(); + return repos.filter((r) => + r.name.toLowerCase().includes(lowerSearch) || + (r.description?.toLowerCase() ?? "").includes(lowerSearch) + ); + }, [repos, debouncedSearch]); + + const sortedRepos = useMemo(() => { + return [...filteredRepos].sort((a, b) => { + if (sortBy === "name") return a.name.localeCompare(b.name); + // Fixed: Using `as any` bypasses strict interface checking for the optional stargazers_count + if (sortBy === "stars") return ((b as any).stargazers_count ?? 0) - ((a as any).stargazers_count ?? 0); + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); + }); + }, [filteredRepos, sortBy]); + + const displayed = limit ? sortedRepos.slice(0, limit) : sortedRepos; + + // -------------------------------------------------------------------------- + // UNAUTHENTICATED RENDER BLOCK + // -------------------------------------------------------------------------- if (status === "unauthenticated" || (status === "authenticated" && !session?.accessToken)) { return ( +
+
+ +
+

Connect your GitHub Identity

+

+ Link your GitHub profile to fetch repositories, analyze codebases, and enable one-click deployments.

@@ -140,44 +346,76 @@ export function RepositoryList({

); } - const filtered = repos.filter((r) => - r.name.toLowerCase().includes(search.toLowerCase()) || - (r.description?.toLowerCase() ?? "").includes(search.toLowerCase()) - ); - const displayed = limit ? filtered.slice(0, limit) : filtered; - + // -------------------------------------------------------------------------- + // MAIN DASHBOARD RENDER + // -------------------------------------------------------------------------- return ( -
- {/* Deploy notification */} - {deployMsg && ( +
+ + {/* FILE INJECTION DROPZONE UI */} +
+
+

+ + Static File Injection +

+

+ Drag and drop `.env` files, configuration assets, or build scripts to inject them directly into your deployment sandbox. +

+
+
- {deployMsg.msg} - {deployMsg.url && ( - - Open Preview - - )} + +

+ {isDragActive ? "Release to stage files..." : "Drag & drop files here"} +

+

or click anywhere in this box to browse

+ +
- )} + {fileError && ( +
+ + {fileError} +
+ )} + + {injectedFiles.length > 0 && ( +
+
+

+ Staged Payload ({injectedFiles.length}) +

+
- )} -
- )} + +
+ {injectedFiles.map((file) => ( +
+
+
+ +
+
+

{file.name}

+

{formatBytes(file.size)}

+
+
+ +
+ ))} +
+
+ )} +
- {/* Error state */} - {error && !loading && ( -
- {error} + {/* Deployment Banner */} + {deployMsg && ( +
+ {deployMsg.ok ? : } + {deployMsg.msg} + {deployMsg.url && View Live Target}
)} + {/* Control Navigation Header */} + {!compact && ( +
+
+ + setSearch(e.target.value)} + className="w-full bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition-all dark:text-white placeholder:text-gray-400 dark:placeholder:text-zinc-500" + /> +
+
+
+ + +
+ {showViewToggle && ( +
+ + +
+ )} {/* Loading skeletons */} {loading && ( view === "grid" ? ( @@ -253,41 +543,34 @@ export function RepositoryList({
- ) - )} - - {/* Empty state */} - {!loading && !error && displayed.length === 0 && ( -
- -

No repositories found

-

- {search ? "Try a different search term." : "Create a repository on GitHub to get started."} -

)} - {/* Grid view */} - {!loading && !error && displayed.length > 0 && view === "grid" && ( -
- {displayed.map((repo) => ( - - ))} + {/* Dynamic Render Tree */} + {error && !loading && ( +
+ {error}
)} - {/* Table view */} - {!loading && !error && displayed.length > 0 && view === "table" && ( - + {loading ? ( + view === "grid" ? : + ) : ( + displayed.length === 0 && !error ? ( + + ) : ( + view === "grid" ? ( +
+ {displayed.map((repo) => ( + + ))} +
+ ) : ( +
+ +
+ ) + ) )}
);