diff --git a/website/components/layouts/Navbar.tsx b/website/components/layouts/Navbar.tsx index e77a9b7f..d6175201 100644 --- a/website/components/layouts/Navbar.tsx +++ b/website/components/layouts/Navbar.tsx @@ -105,6 +105,10 @@ const Navbar = () => {
+ + diff --git a/website/content/docs/cli.mdx b/website/content/docs/cli.mdx index a04720a4..4f687e58 100644 --- a/website/content/docs/cli.mdx +++ b/website/content/docs/cli.mdx @@ -116,6 +116,12 @@ Use `dbdev install` to install a local TLE package into the connected database: dbdev install --connection "postgresql://postgres:postgres@localhost:54322/postgres" path --directory ./pg_idkit ``` +Or for a local package like `supa_privacy` or `supa_profile`: + +```bash +dbdev install --connection "postgresql://postgres:postgres@localhost:54322/postgres" path --directory ./supa_privacy +``` + The `path` source defaults to the current directory when `--directory` is omitted. ### List Installed Package Versions diff --git a/website/content/docs/install-a-package.mdx b/website/content/docs/install-a-package.mdx index 8e42fee7..1f6b8c86 100644 --- a/website/content/docs/install-a-package.mdx +++ b/website/content/docs/install-a-package.mdx @@ -35,6 +35,19 @@ For example, to install `kiwicopple@pg_idkit` version 0.0.4 in `extensions` sche dbdev add -o "./migrations/" -v 0.0.4 -s extensions package -n kiwicopple@pg_idkit ``` +Or to install the `jvent@supa_privacy` data anonymisation extension: + +```bash +dbdev add -o "./migrations/" -v 1.0.0 -s extensions package -n jvent@supa_privacy +``` + +Or to install the `jvent@supa_profile` table profiling extension: + +```bash +dbdev add -o "./migrations/" -v 1.0.0 -s extensions package -n jvent@supa_profile +``` + + To create a migration file to update to the latest version of a package, you need to specify the `-c` flag with the connection string to your database. The connection is used to check the current version of the package installed in the database and generate a migration file that will update it to the latest version available on database.dev. ```bash diff --git a/website/next-env.d.ts b/website/next-env.d.ts index 1a1bf607..0c7fad71 100644 --- a/website/next-env.d.ts +++ b/website/next-env.d.ts @@ -1,6 +1,7 @@ /// /// -import './.next/types/routes.d.ts' +/// +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/website/pages/packages.tsx b/website/pages/packages.tsx new file mode 100644 index 00000000..cdb217ef --- /dev/null +++ b/website/pages/packages.tsx @@ -0,0 +1,624 @@ +import { useState, useEffect, useMemo } from 'react' +import Head from 'next/head' +import Link from 'next/link' +import Layout from '~/components/layouts/Layout' +import { Button } from '~/components/ui/button' +import { Input } from '~/components/ui/input' +import { Badge } from '~/components/ui/badge' +import { toast } from '~/hooks/use-toast' +import dayjs from '~/lib/dayjs' +import { NextPageWithLayout } from '~/lib/types' +import { + Search, + ChevronDown, + ChevronUp, + Copy, + Check, + ExternalLink, + RotateCcw, + Package as PackageIcon, + SlidersHorizontal, + Calendar, + Layers, + User, + Tag +} from 'lucide-react' + +// Define the shape of each package according to database.dev response API +interface Package { + id: string + package_name: string + handle: string + partial_name: string + latest_version: string + description_md: string + control_description: string + control_requires: string[] + created_at: string + default_version: string + package_alias: string | null +} + +const PackagesPage: NextPageWithLayout = () => { + const [packages, setPackages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filter & Search states + const [searchQuery, setSearchQuery] = useState('') + const [selectedPublisher, setSelectedPublisher] = useState('all') + const [selectedRequires, setSelectedRequires] = useState('all') + + // Sorting state + const [sortField, setSortField] = useState<'package_name' | 'handle' | 'latest_version' | 'created_at'>('created_at') + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') + + // Pagination state + const [currentPage, setCurrentPage] = useState(1) + const [itemsPerPage, setItemsPerPage] = useState(10) + + // Copy success animation state per package + const [copiedPackageId, setCopiedPackageId] = useState(null) + + // Fetch packages from the public endpoint + const fetchPackages = async () => { + setLoading(true) + setError(null) + try { + const response = await fetch('https://api.database.dev/rest/v1/rpc/search_packages', { + method: 'POST', + headers: { + 'accept': '*/*', + 'accept-language': 'en-US,en;q=0.9', + 'apikey': 'sb_publishable_044WUFe74ISl9ARZlSkDAQ_3jFCCRle', + 'content-type': 'application/json', + 'x-client-info': 'supabase-js/2.107.0; runtime=web', + 'Referer': 'https://database.dev/' + }, + body: JSON.stringify({ handle: '' }), + }) + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + setPackages(data || []) + } catch (err: any) { + setError(err.message || 'An unexpected error occurred while fetching packages.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchPackages() + }, []) + + // Copy install command to clipboard + const handleCopyCommand = async (pkg: Package) => { + const alias = pkg.package_alias ?? pkg.package_name + const version = pkg.latest_version ?? '0.0.0' + const command = `dbdev add -o ./migrations -s extensions -v ${version} package -n "${alias}"` + + try { + await navigator.clipboard.writeText(command) + setCopiedPackageId(pkg.id) + toast.success(`Copied installation command for ${alias}!`) + setTimeout(() => { + setCopiedPackageId(null) + }, 2000) + } catch (err) { + toast.error('Failed to copy command to clipboard') + } + } + + // Get unique options for filter dropdowns + const publishers = useMemo(() => { + const unique = new Set(packages.map((pkg) => pkg.handle).filter(Boolean)) + return Array.from(unique).sort() + }, [packages]) + + const requiredExtensions = useMemo(() => { + const unique = new Set() + packages.forEach((pkg) => { + if (Array.isArray(pkg.control_requires)) { + pkg.control_requires.forEach((req) => unique.add(req)) + } + }) + return Array.from(unique).sort() + }, [packages]) + + // Filter packages based on query, publisher, and required extensions + const filteredPackages = useMemo(() => { + return packages.filter((pkg) => { + // 1. Search Query filter (matches name, alias, handle, descriptions) + const query = searchQuery.toLowerCase().trim() + const matchesSearch = + !query || + pkg.package_name.toLowerCase().includes(query) || + (pkg.package_alias && pkg.package_alias.toLowerCase().includes(query)) || + pkg.handle.toLowerCase().includes(query) || + (pkg.control_description && pkg.control_description.toLowerCase().includes(query)) || + (pkg.description_md && pkg.description_md.toLowerCase().includes(query)) + + // 2. Publisher filter + const matchesPublisher = selectedPublisher === 'all' || pkg.handle === selectedPublisher + + // 3. Required extension filter + const matchesRequires = + selectedRequires === 'all' || + (Array.isArray(pkg.control_requires) && pkg.control_requires.includes(selectedRequires)) + + return matchesSearch && matchesPublisher && matchesRequires + }) + }, [packages, searchQuery, selectedPublisher, selectedRequires]) + + // Sort filtered packages + const sortedPackages = useMemo(() => { + const sorted = [...filteredPackages] + sorted.sort((a, b) => { + let aVal = a[sortField] || '' + let bVal = b[sortField] || '' + + if (sortField === 'created_at') { + return sortDirection === 'asc' + ? new Date(aVal).getTime() - new Date(bVal).getTime() + : new Date(bVal).getTime() - new Date(aVal).getTime() + } + + if (typeof aVal === 'string' && typeof bVal === 'string') { + return sortDirection === 'asc' + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) + } + + return 0 + }) + return sorted + }, [filteredPackages, sortField, sortDirection]) + + // Pagination calculations + const paginatedPackages = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage + return sortedPackages.slice(startIndex, startIndex + itemsPerPage) + }, [sortedPackages, currentPage, itemsPerPage]) + + const totalPages = Math.ceil(sortedPackages.length / itemsPerPage) + + const handleSort = (field: typeof sortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortField(field) + setSortDirection('asc') + } + setCurrentPage(1) + } + + const handleResetFilters = () => { + setSearchQuery('') + setSelectedPublisher('all') + setSelectedRequires('all') + setSortField('created_at') + setSortDirection('desc') + setCurrentPage(1) + } + + // Effect to reset page number if filters or page sizes change + useEffect(() => { + setCurrentPage(1) + }, [searchQuery, selectedPublisher, selectedRequires, itemsPerPage]) + + return ( + <> + + Packages Explorer | dbdev + + +
+ {/* Header Block */} +
+
+ +
+
+ + Database Extensions + +

+ Packages Explorer +

+

+ Discover, filter, and search packages for postgres Trusted Language Extensions (pg_tle). + Install extensions in your local migrations or client with ease. +

+
+
+ + {/* Filters and Controls */} +
+
+
+ + Search and Filters +
+ {(searchQuery || selectedPublisher !== 'all' || selectedRequires !== 'all') && ( + + )} +
+ +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 h-10 w-full bg-slate-50/50 dark:bg-slate-950/50 border-slate-200 dark:border-slate-800 focus-visible:ring-emerald-500" + /> +
+ + {/* Publisher Dropdown */} +
+ +
+ + {/* Requires Dropdown */} +
+ +
+
+
+ + {/* Error State */} + {error && ( +
+

Failed to load packages

+

{error}

+ +
+ )} + + {/* Loading State */} + {loading && ( +
+
+
+
+
+
+ {[...Array(5)].map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ )} + + {/* Loaded Data Table */} + {!loading && !error && ( + <> +
+
+ + + + + + + + + + + + + + {paginatedPackages.length === 0 ? ( + + + + ) : ( + paginatedPackages.map((pkg) => ( + + + + + + + + + + )) + )} + +
handleSort('package_name')} + > +
+ + Package Name + {sortField === 'package_name' && ( + sortDirection === 'asc' ? : + )} +
+
handleSort('handle')} + > +
+ + Publisher + {sortField === 'handle' && ( + sortDirection === 'asc' ? : + )} +
+
Description +
+ + Version +
+
+
+ + Requires +
+
handleSort('created_at')} + > +
+ + Created + {sortField === 'created_at' && ( + sortDirection === 'asc' ? : + )} +
+
Actions
+
+ + No packages found + Try adjusting your filters or search query. + +
+
+ + {pkg.package_alias ?? pkg.package_name} + {pkg.package_alias && ( + + {pkg.package_name} + + )} + + + +
+
+ {pkg.handle.substring(0, 2)} +
+ + {pkg.handle} + +
+ +
+ {pkg.control_description || pkg.description_md ? ( +

+ {pkg.control_description || pkg.description_md} +

+ ) : ( + No description provided + )} +
+ + v{pkg.latest_version} + + +
+ {Array.isArray(pkg.control_requires) && pkg.control_requires.length > 0 ? ( + pkg.control_requires.map((req) => ( + + {req} + + )) + ) : ( + - + )} +
+
+ {dayjs(pkg.created_at).fromNow()} + +
+ {/* Copy CLI installation command */} + + + {/* Link to detail page */} + +
+
+
+ + {/* Table Footer Controls */} + {sortedPackages.length > 0 && ( +
+ {/* Items count */} + + Showing{' '} + + {Math.min(sortedPackages.length, (currentPage - 1) * itemsPerPage + 1)} + {' '} + to{' '} + + {Math.min(sortedPackages.length, currentPage * itemsPerPage)} + {' '} + of{' '} + + {sortedPackages.length} + {' '} + packages + + + {/* Pagination Controls */} +
+ {/* Rows per page selector */} +
+ Rows: + +
+ +
+ + {[...Array(totalPages)].map((_, idx) => { + const pageNum = idx + 1 + // Logic to only show immediate pages if total pages are high + if ( + totalPages > 5 && + pageNum !== 1 && + pageNum !== totalPages && + Math.abs(pageNum - currentPage) > 1 + ) { + if (pageNum === 2 || pageNum === totalPages - 1) { + return ... + } + return null + } + + return ( + + ) + })} + +
+
+
+ )} +
+ + )} +
+ + ) +} + +PackagesPage.getLayout = (page) => {page} + +export default PackagesPage diff --git a/website/tsconfig.json b/website/tsconfig.json index d3c556f8..865e1ede 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -2,7 +2,11 @@ "compilerOptions": { "ignoreDeprecations": "5.0", "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -17,12 +21,30 @@ "incremental": true, "baseUrl": ".", "paths": { - "~/*": ["./*"], - "@/*": ["./*"], - "@/.source": ["./.source"] - } + "~/*": [ + "./*" + ], + "@/*": [ + "./*" + ], + "@/.source": [ + "./.source" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".source/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".source/**/*.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], "exclude": [ "node_modules", "vitest.config.ts",