From cba930e4d39dab49b10001853ad21a424da06122 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Wed, 9 Apr 2025 21:34:26 +0000 Subject: [PATCH] Implement initial application structure and UI. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9111ef36-26c8-4085-84ca-a35dc1fec1b5 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7083d608-d6d3-4a6a-9a27-6286c5109627/54918520-d42c-44cd-b4f4-9e5a42eb5be1.jpg --- .gitignore | 6 + .replit | 42 + client/index.html | 11 + client/src/App.tsx | 54 + .../components/activity/recent-activity.tsx | 117 + .../components/charts/dns-update-chart.tsx | 138 + .../charts/provider-distribution-chart.tsx | 98 + .../components/charts/record-type-chart.tsx | 88 + .../components/domain/domain-status-card.tsx | 25 + client/src/components/domain/domain-table.tsx | 227 + client/src/components/layouts/header.tsx | 87 + client/src/components/layouts/main-layout.tsx | 48 + .../src/components/layouts/mobile-sidebar.tsx | 188 + client/src/components/layouts/sidebar.tsx | 165 + .../shared/organization-selector.tsx | 63 + client/src/components/shared/pagination.tsx | 110 + client/src/components/shared/theme-toggle.tsx | 24 + client/src/components/ui/accordion.tsx | 56 + client/src/components/ui/alert-dialog.tsx | 139 + client/src/components/ui/alert.tsx | 59 + client/src/components/ui/aspect-ratio.tsx | 5 + client/src/components/ui/avatar.tsx | 48 + client/src/components/ui/badge.tsx | 36 + client/src/components/ui/breadcrumb.tsx | 126 + client/src/components/ui/button.tsx | 56 + client/src/components/ui/calendar.tsx | 64 + client/src/components/ui/card.tsx | 79 + client/src/components/ui/carousel.tsx | 260 + client/src/components/ui/chart.tsx | 363 + client/src/components/ui/checkbox.tsx | 28 + client/src/components/ui/collapsible.tsx | 9 + client/src/components/ui/command.tsx | 153 + client/src/components/ui/context-menu.tsx | 198 + client/src/components/ui/dialog.tsx | 120 + client/src/components/ui/drawer.tsx | 116 + client/src/components/ui/dropdown-menu.tsx | 198 + client/src/components/ui/form.tsx | 176 + client/src/components/ui/hover-card.tsx | 27 + client/src/components/ui/input-otp.tsx | 69 + client/src/components/ui/input.tsx | 25 + client/src/components/ui/label.tsx | 24 + client/src/components/ui/menubar.tsx | 234 + client/src/components/ui/navigation-menu.tsx | 128 + client/src/components/ui/pagination.tsx | 117 + client/src/components/ui/popover.tsx | 29 + client/src/components/ui/progress.tsx | 26 + client/src/components/ui/radio-group.tsx | 42 + client/src/components/ui/resizable.tsx | 43 + client/src/components/ui/scroll-area.tsx | 46 + client/src/components/ui/select.tsx | 158 + client/src/components/ui/separator.tsx | 29 + client/src/components/ui/sheet.tsx | 138 + client/src/components/ui/sidebar.tsx | 762 ++ client/src/components/ui/skeleton.tsx | 15 + client/src/components/ui/slider.tsx | 26 + client/src/components/ui/switch.tsx | 27 + client/src/components/ui/table.tsx | 117 + client/src/components/ui/tabs.tsx | 53 + client/src/components/ui/textarea.tsx | 24 + client/src/components/ui/toast.tsx | 127 + client/src/components/ui/toaster.tsx | 33 + client/src/components/ui/toggle-group.tsx | 59 + client/src/components/ui/toggle.tsx | 43 + client/src/components/ui/tooltip.tsx | 28 + client/src/context/organization-context.tsx | 59 + client/src/hooks/use-auth.tsx | 121 + client/src/hooks/use-mobile.tsx | 19 + client/src/hooks/use-theme.tsx | 78 + client/src/hooks/use-toast.ts | 191 + client/src/index.css | 13 + client/src/lib/protected-route.tsx | 33 + client/src/lib/queryClient.ts | 57 + client/src/lib/utils.ts | 6 + client/src/main.tsx | 5 + client/src/pages/api-tokens.tsx | 491 + client/src/pages/auth-page.tsx | 273 + client/src/pages/dashboard.tsx | 138 + client/src/pages/dns-records.tsx | 778 ++ client/src/pages/domains.tsx | 287 + client/src/pages/history.tsx | 266 + client/src/pages/metrics.tsx | 266 + client/src/pages/not-found.tsx | 21 + client/src/pages/providers.tsx | 581 ++ client/src/pages/settings.tsx | 612 ++ client/src/pages/users-roles.tsx | 708 ++ drizzle.config.ts | 14 + package-lock.json | 8815 +++++++++++++++++ package.json | 103 + postcss.config.js | 6 + server/auth.ts | 205 + server/index.ts | 70 + server/routes.ts | 672 ++ server/storage.ts | 389 + server/vite.ts | 85 + shared/schema.ts | 160 + tailwind.config.ts | 90 + theme.json | 6 + tsconfig.json | 23 + vite.config.ts | 33 + 99 files changed, 21903 insertions(+) create mode 100644 .gitignore create mode 100644 .replit create mode 100644 client/index.html create mode 100644 client/src/App.tsx create mode 100644 client/src/components/activity/recent-activity.tsx create mode 100644 client/src/components/charts/dns-update-chart.tsx create mode 100644 client/src/components/charts/provider-distribution-chart.tsx create mode 100644 client/src/components/charts/record-type-chart.tsx create mode 100644 client/src/components/domain/domain-status-card.tsx create mode 100644 client/src/components/domain/domain-table.tsx create mode 100644 client/src/components/layouts/header.tsx create mode 100644 client/src/components/layouts/main-layout.tsx create mode 100644 client/src/components/layouts/mobile-sidebar.tsx create mode 100644 client/src/components/layouts/sidebar.tsx create mode 100644 client/src/components/shared/organization-selector.tsx create mode 100644 client/src/components/shared/pagination.tsx create mode 100644 client/src/components/shared/theme-toggle.tsx create mode 100644 client/src/components/ui/accordion.tsx create mode 100644 client/src/components/ui/alert-dialog.tsx create mode 100644 client/src/components/ui/alert.tsx create mode 100644 client/src/components/ui/aspect-ratio.tsx create mode 100644 client/src/components/ui/avatar.tsx create mode 100644 client/src/components/ui/badge.tsx create mode 100644 client/src/components/ui/breadcrumb.tsx create mode 100644 client/src/components/ui/button.tsx create mode 100644 client/src/components/ui/calendar.tsx create mode 100644 client/src/components/ui/card.tsx create mode 100644 client/src/components/ui/carousel.tsx create mode 100644 client/src/components/ui/chart.tsx create mode 100644 client/src/components/ui/checkbox.tsx create mode 100644 client/src/components/ui/collapsible.tsx create mode 100644 client/src/components/ui/command.tsx create mode 100644 client/src/components/ui/context-menu.tsx create mode 100644 client/src/components/ui/dialog.tsx create mode 100644 client/src/components/ui/drawer.tsx create mode 100644 client/src/components/ui/dropdown-menu.tsx create mode 100644 client/src/components/ui/form.tsx create mode 100644 client/src/components/ui/hover-card.tsx create mode 100644 client/src/components/ui/input-otp.tsx create mode 100644 client/src/components/ui/input.tsx create mode 100644 client/src/components/ui/label.tsx create mode 100644 client/src/components/ui/menubar.tsx create mode 100644 client/src/components/ui/navigation-menu.tsx create mode 100644 client/src/components/ui/pagination.tsx create mode 100644 client/src/components/ui/popover.tsx create mode 100644 client/src/components/ui/progress.tsx create mode 100644 client/src/components/ui/radio-group.tsx create mode 100644 client/src/components/ui/resizable.tsx create mode 100644 client/src/components/ui/scroll-area.tsx create mode 100644 client/src/components/ui/select.tsx create mode 100644 client/src/components/ui/separator.tsx create mode 100644 client/src/components/ui/sheet.tsx create mode 100644 client/src/components/ui/sidebar.tsx create mode 100644 client/src/components/ui/skeleton.tsx create mode 100644 client/src/components/ui/slider.tsx create mode 100644 client/src/components/ui/switch.tsx create mode 100644 client/src/components/ui/table.tsx create mode 100644 client/src/components/ui/tabs.tsx create mode 100644 client/src/components/ui/textarea.tsx create mode 100644 client/src/components/ui/toast.tsx create mode 100644 client/src/components/ui/toaster.tsx create mode 100644 client/src/components/ui/toggle-group.tsx create mode 100644 client/src/components/ui/toggle.tsx create mode 100644 client/src/components/ui/tooltip.tsx create mode 100644 client/src/context/organization-context.tsx create mode 100644 client/src/hooks/use-auth.tsx create mode 100644 client/src/hooks/use-mobile.tsx create mode 100644 client/src/hooks/use-theme.tsx create mode 100644 client/src/hooks/use-toast.ts create mode 100644 client/src/index.css create mode 100644 client/src/lib/protected-route.tsx create mode 100644 client/src/lib/queryClient.ts create mode 100644 client/src/lib/utils.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/api-tokens.tsx create mode 100644 client/src/pages/auth-page.tsx create mode 100644 client/src/pages/dashboard.tsx create mode 100644 client/src/pages/dns-records.tsx create mode 100644 client/src/pages/domains.tsx create mode 100644 client/src/pages/history.tsx create mode 100644 client/src/pages/metrics.tsx create mode 100644 client/src/pages/not-found.tsx create mode 100644 client/src/pages/providers.tsx create mode 100644 client/src/pages/settings.tsx create mode 100644 client/src/pages/users-roles.tsx create mode 100644 drizzle.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 server/auth.ts create mode 100644 server/index.ts create mode 100644 server/routes.ts create mode 100644 server/storage.ts create mode 100644 server/vite.ts create mode 100644 shared/schema.ts create mode 100644 tailwind.config.ts create mode 100644 theme.json create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9ba7f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.DS_Store +server/public +vite.config.ts.* +*.tar.gz \ No newline at end of file diff --git a/.replit b/.replit new file mode 100644 index 0000000..66129d4 --- /dev/null +++ b/.replit @@ -0,0 +1,42 @@ +modules = ["nodejs-20", "bash", "web"] +run = "npm run dev" +hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"] + +[nix] +channel = "stable-24_05" + +[deployment] +deploymentTarget = "autoscale" +run = ["npm", "run", "start"] +build = ["npm", "run", "build"] + +[[ports]] +localPort = 5000 +externalPort = 80 + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[workflows.workflow.metadata] +agentRequireRestartOnSave = false + +[[workflows.workflow.tasks]] +task = "packager.installForAll" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npm run dev" +waitForPort = 5000 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..ed27b80 --- /dev/null +++ b/client/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..164f567 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,54 @@ +import { Switch, Route } from "wouter"; +import { queryClient } from "./lib/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import NotFound from "@/pages/not-found"; +import AuthPage from "@/pages/auth-page"; +import DashboardPage from "@/pages/dashboard"; +import DomainsPage from "@/pages/domains"; +import DnsRecordsPage from "@/pages/dns-records"; +import MetricsPage from "@/pages/metrics"; +import HistoryPage from "@/pages/history"; +import ApiTokensPage from "@/pages/api-tokens"; +import UsersRolesPage from "@/pages/users-roles"; +import ProvidersPage from "@/pages/providers"; +import SettingsPage from "@/pages/settings"; +import { AuthProvider } from "@/hooks/use-auth"; +import { ProtectedRoute } from "@/lib/protected-route"; +import { ThemeProvider } from "@/hooks/use-theme"; +import { OrganizationProvider } from "@/context/organization-context"; + +function Router() { + return ( + + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + + + ); +} + +export default App; diff --git a/client/src/components/activity/recent-activity.tsx b/client/src/components/activity/recent-activity.tsx new file mode 100644 index 0000000..4273d6c --- /dev/null +++ b/client/src/components/activity/recent-activity.tsx @@ -0,0 +1,117 @@ +import { useQuery } from "@tanstack/react-query"; +import { DnsHistory } from "@shared/schema"; +import { formatDistanceToNow } from "date-fns"; +import { Loader2 } from "lucide-react"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; + +interface RecentActivityProps { + domainId?: number; +} + +export function RecentActivity({ domainId }: RecentActivityProps) { + // Fetch history data for the specified domain or all domains if not specified + const queryKey = domainId + ? ["/api/dns-history/domain", domainId] + : ["/api/dns-history"]; + + const { data: historyEntries = [], isLoading } = useQuery({ + queryKey, + enabled: domainId !== undefined, + }); + + // Limit to last 5 entries + const recentEntries = historyEntries.slice(0, 5); + + const getActivityIcon = (action: string) => { + switch (action) { + case "create": + return "border-success"; + case "update": + return "border-primary"; + case "delete": + return "border-destructive"; + default: + return "border-muted-foreground"; + } + }; + + const getActivityTitle = (entry: DnsHistory) => { + if (!entry.previousValue || !entry.newValue) return "Unknown action"; + + try { + const prev = JSON.parse(entry.previousValue); + const curr = JSON.parse(entry.newValue); + + switch (entry.action) { + case "create": + return `${curr.name} ${curr.type} record created`; + case "update": + return `${curr.name} ${curr.type} record updated`; + case "delete": + return `${prev.name} ${prev.type} record deleted`; + default: + return "Unknown action"; + } + } catch (e) { + return "Error parsing history"; + } + }; + + const getActivityDetails = (entry: DnsHistory) => { + if (!entry.newValue) return ""; + + try { + const data = JSON.parse(entry.newValue); + switch (entry.action) { + case "create": + case "update": + return `Value: ${data.content}`; + default: + return ""; + } + } catch (e) { + return ""; + } + }; + + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + return ( + + + Recent Activity + {recentEntries.length === 0 ? ( +
+ No recent activity found +
+ ) : ( +
+ {recentEntries.map((entry) => ( +
+
+

{getActivityTitle(entry)}

+

{getActivityDetails(entry)}

+

+ {formatDistanceToNow(new Date(entry.timestamp), { addSuffix: true })} +

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/client/src/components/charts/dns-update-chart.tsx b/client/src/components/charts/dns-update-chart.tsx new file mode 100644 index 0000000..36ff426 --- /dev/null +++ b/client/src/components/charts/dns-update-chart.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; + +interface DnsUpdateChartProps { + timeframe?: string; + domainId?: number; +} + +export function DnsUpdateChart({ + timeframe = "day", + domainId +}: DnsUpdateChartProps) { + // This would be a real API query in a production app + // For this MVP, we'll generate sample data + + // Create a query key that includes the timeframe and domainId + const queryKey = ["/api/metrics/dns-updates", timeframe, domainId]; + + // In a real app, this would fetch data from the API + const { data: chartData, isLoading } = useQuery({ + queryKey, + queryFn: async () => generateSampleData(timeframe), + // Keep data fresh for 5 minutes + staleTime: 5 * 60 * 1000, + }); + + // Generate sample data based on timeframe + // In a real application, this would come from the API + const generateSampleData = (timeframe: string) => { + const data = []; + + if (timeframe === "day") { + // Generate hourly data for a day + for (let i = 0; i < 24; i++) { + const hour = i.toString().padStart(2, "0") + ":00"; + const updateCount = Math.floor(Math.random() * 10) + 1; + data.push({ + time: hour, + updates: updateCount, + }); + } + } else if (timeframe === "week") { + // Generate daily data for a week + const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + for (let i = 0; i < 7; i++) { + const updateCount = Math.floor(Math.random() * 50) + 10; + data.push({ + time: days[i], + updates: updateCount, + }); + } + } else if (timeframe === "month") { + // Generate weekly data for a month + for (let i = 1; i <= 4; i++) { + const updateCount = Math.floor(Math.random() * 200) + 50; + data.push({ + time: `Week ${i}`, + updates: updateCount, + }); + } + } + + return data; + }; + + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + return ( +
+ + {timeframe === "day" ? ( + + + value.split(":")[0]} + /> + + [`${value} updates`, "Updates"]} + labelFormatter={(label) => `Time: ${label}`} + /> + + + ) : ( + + + + + [`${value} updates`, "Updates"]} + labelFormatter={(label) => `${label}`} + /> + + + )} + +
+ ); +} diff --git a/client/src/components/charts/provider-distribution-chart.tsx b/client/src/components/charts/provider-distribution-chart.tsx new file mode 100644 index 0000000..09c071c --- /dev/null +++ b/client/src/components/charts/provider-distribution-chart.tsx @@ -0,0 +1,98 @@ +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Loader2, Cloud, Home, CircleHelp } from "lucide-react"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Legend, + Tooltip, +} from "recharts"; + +export function ProviderDistributionChart() { + // This would be a real API query in a production app + // For this MVP, we'll generate sample data + + // In a real app, this would fetch data from the API + const { data: chartData, isLoading } = useQuery({ + queryKey: ["/api/metrics/provider-distribution"], + queryFn: async () => generateSampleData(), + // Keep data fresh for 5 minutes + staleTime: 5 * 60 * 1000, + }); + + // Generate sample data + // In a real application, this would come from the API + const generateSampleData = () => { + return [ + { name: "Cloudflare", value: 45, color: "hsl(var(--primary))" }, + { name: "Route53", value: 30, color: "hsl(var(--success))" }, + { name: "GoDaddy", value: 15, color: "hsl(var(--warning))" }, + { name: "Others", value: 10, color: "hsl(var(--destructive))" }, + ]; + }; + + // Simple icon renderer for the legend + const renderCustomizedLegend = (props: any) => { + const { payload } = props; + + return ( + + ); + }; + + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + return ( + + +

DNS Provider Distribution

+
+ + + + {chartData.map((entry, index) => ( + + ))} + + [`${value}%`, "Percentage"]} + /> + + + +
+
+
+ ); +} diff --git a/client/src/components/charts/record-type-chart.tsx b/client/src/components/charts/record-type-chart.tsx new file mode 100644 index 0000000..1e566ed --- /dev/null +++ b/client/src/components/charts/record-type-chart.tsx @@ -0,0 +1,88 @@ +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; + +export function RecordTypeChart() { + // This would be a real API query in a production app + // For this MVP, we'll generate sample data + + // In a real app, this would fetch data from the API + const { data: chartData, isLoading } = useQuery({ + queryKey: ["/api/metrics/record-type-distribution"], + queryFn: async () => generateSampleData(), + // Keep data fresh for 5 minutes + staleTime: 5 * 60 * 1000, + }); + + // Generate sample data + // In a real application, this would come from the API + const generateSampleData = () => { + return [ + { name: "A", count: 70 }, + { name: "AAAA", count: 20 }, + { name: "CNAME", count: 45 }, + { name: "MX", count: 25 }, + { name: "TXT", count: 15 }, + { name: "SRV", count: 10 }, + { name: "Other", count: 5 }, + ]; + }; + + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + return ( + + +

Record Type Distribution

+
+ + + + + + [`${value} records`, "Count"]} + labelFormatter={(label) => `${label} Records`} + /> + + + +
+
+
+ ); +} diff --git a/client/src/components/domain/domain-status-card.tsx b/client/src/components/domain/domain-status-card.tsx new file mode 100644 index 0000000..88e8ebe --- /dev/null +++ b/client/src/components/domain/domain-status-card.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; +import { Card } from "@/components/ui/card"; + +interface DomainStatusCardProps { + title: string; + value: string | number; + icon: ReactNode; + iconClassName?: string; +} + +export function DomainStatusCard({ title, value, icon, iconClassName = "bg-primary/10 text-primary" }: DomainStatusCardProps) { + return ( + +
+
+ {icon} +
+
+

{title}

+

{value}

+
+
+
+ ); +} diff --git a/client/src/components/domain/domain-table.tsx b/client/src/components/domain/domain-table.tsx new file mode 100644 index 0000000..8c92616 --- /dev/null +++ b/client/src/components/domain/domain-table.tsx @@ -0,0 +1,227 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Domain } from "@shared/schema"; +import { Link } from "wouter"; +import { useOrganization } from "@/context/organization-context"; +import { Pagination } from "@/components/shared/pagination"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { formatDistanceToNow } from "date-fns"; +import { Cloud, Home, MoreVertical, Loader2, CircleHelp } from "lucide-react"; + +interface DomainTableProps { + onManageDomain?: (domain: Domain) => void; + onDeleteDomain?: (domain: Domain) => void; +} + +export function DomainTable({ onManageDomain, onDeleteDomain }: DomainTableProps) { + const { currentOrganization } = useOrganization(); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 5; + + const { data: domains = [], isLoading } = useQuery({ + queryKey: ["/api/domains", currentOrganization?.id], + enabled: !!currentOrganization, + }); + + // Get providers to display provider names + const { data: providers = [] } = useQuery({ + queryKey: ["/api/providers"], + }); + + const getProviderName = (providerId: number) => { + const provider = providers.find(p => p.id === providerId); + return provider?.name || "Unknown"; + }; + + const getProviderIcon = (providerId: number) => { + const provider = providers.find(p => p.id === providerId); + + if (!provider) return ; + + switch (provider.type) { + case "cloudflare": + return ; + case "route53": + return ; + default: + return ; + } + }; + + const getStatusBadge = (domain: Domain) => { + if (!domain.isActive) { + return ( + + Inactive + + ); + } + + // If updated within the last hour, show "Active" + if (domain.lastUpdated && new Date(domain.lastUpdated).getTime() > Date.now() - 3600000) { + return ( + + Active + + ); + } + + // If updated more than 24 hours ago, show "Update Pending" + if (domain.lastUpdated && new Date(domain.lastUpdated).getTime() < Date.now() - 86400000) { + return ( + + Update Pending + + ); + } + + return ( + + Active + + ); + }; + + // Calculate pagination + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedDomains = domains.slice(startIndex, endIndex); + const totalPages = Math.ceil(domains.length / pageSize); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (domains.length === 0) { + return ( +
+
+ +
+

No domains found

+

+ Get started by adding your first domain. +

+
+ +
+
+ ); + } + + return ( +
+
+

Managed Domains

+ +
+ +
+ + + + Domain + Provider + Records + Last Update + Status + Actions + + + + {paginatedDomains.map(domain => ( + + {domain.name} + +
+ {getProviderIcon(domain.providerId)} + {getProviderName(domain.providerId)} +
+
+ + + View Records + + + + {domain.lastUpdated + ? formatDistanceToNow(new Date(domain.lastUpdated), { addSuffix: true }) + : "Never"} + + {getStatusBadge(domain)} + + + + + + + onManageDomain && onManageDomain(domain)} + > + Manage + + onDeleteDomain && onDeleteDomain(domain)} + className="text-destructive focus:text-destructive" + > + Delete + + + + +
+ ))} +
+
+
+ +
+ +
+
+ ); +} diff --git a/client/src/components/layouts/header.tsx b/client/src/components/layouts/header.tsx new file mode 100644 index 0000000..9dcf4bd --- /dev/null +++ b/client/src/components/layouts/header.tsx @@ -0,0 +1,87 @@ +import { Menu, User } from "lucide-react"; +import { ThemeToggle } from "@/components/shared/theme-toggle"; +import { useAuth } from "@/hooks/use-auth"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +interface HeaderProps { + title?: string; + onMobileMenuOpen: () => void; +} + +export function Header({ title, onMobileMenuOpen }: HeaderProps) { + const { user, logoutMutation } = useAuth(); + + const getInitials = (name: string) => { + return name + .split(' ') + .map(n => n[0]) + .join('') + .toUpperCase(); + }; + + return ( +
+
+ +
+ + + + + {title ? title : "DynamiDNS"} + +
+
+ +
+ + + + + + + + logoutMutation.mutate()} + disabled={logoutMutation.isPending} + > + Log out + + + +
+
+ ); +} diff --git a/client/src/components/layouts/main-layout.tsx b/client/src/components/layouts/main-layout.tsx new file mode 100644 index 0000000..94de825 --- /dev/null +++ b/client/src/components/layouts/main-layout.tsx @@ -0,0 +1,48 @@ +import { ReactNode, useState } from "react"; +import { Sidebar } from "./sidebar"; +import { Header } from "./header"; +import { MobileSidebar } from "./mobile-sidebar"; + +interface MainLayoutProps { + children: ReactNode; + title?: string; + description?: string; +} + +export function MainLayout({ children, title, description }: MainLayoutProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* Mobile Header */} +
setSidebarOpen(true)} + /> + +
+ {/* Sidebar Navigation */} + + + {/* Mobile Sidebar */} + setSidebarOpen(false)} + /> + + {/* Main Content */} +
+
+ {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + {children} +
+
+
+
+ ); +} diff --git a/client/src/components/layouts/mobile-sidebar.tsx b/client/src/components/layouts/mobile-sidebar.tsx new file mode 100644 index 0000000..5b37989 --- /dev/null +++ b/client/src/components/layouts/mobile-sidebar.tsx @@ -0,0 +1,188 @@ +import { Link, useLocation } from "wouter"; +import { cn } from "@/lib/utils"; +import { OrganizationSelector } from "@/components/shared/organization-selector"; +import { useAuth } from "@/hooks/use-auth"; +import { + LayoutDashboard, + Home, + Globe, + BarChart2, + FileEdit, + Key, + Users, + Settings, + CreditCard, + X, + LogOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface MobileSidebarProps { + isOpen: boolean; + onClose: () => void; +} + +export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) { + const [location] = useLocation(); + const { user, logoutMutation } = useAuth(); + + // Define navigation items + const navItems = [ + { + title: "Dashboard", + href: "/", + icon: , + }, + { + title: "Domains", + href: "/domains", + icon: , + }, + { + title: "DNS Records", + href: "/dns-records", + icon: , + }, + { + title: "Metrics", + href: "/metrics", + icon: , + }, + { + title: "History", + href: "/history", + icon: , + }, + { + title: "API Tokens", + href: "/api-tokens", + icon: , + }, + ]; + + // Define admin navigation items + const adminNavItems = [ + { + title: "Users & Roles", + href: "/users-roles", + icon: , + roles: ["admin"], + }, + { + title: "Providers", + href: "/providers", + icon: , + roles: ["admin", "manager"], + }, + { + title: "Settings", + href: "/settings", + icon: , + roles: ["admin", "manager"], + }, + ]; + + // Filter admin items based on user role + const filteredAdminItems = adminNavItems.filter( + item => !item.roles || (user && item.roles.includes(user.role)) + ); + + if (!isOpen) return null; + + return ( +
+
+
+
+
+
+ + + + DynamiDNS +
+ +
+ + {/* Organization Selector */} +
+ +
+ + + +
+ +
+
+
+
+ ); +} diff --git a/client/src/components/layouts/sidebar.tsx b/client/src/components/layouts/sidebar.tsx new file mode 100644 index 0000000..8c0bb49 --- /dev/null +++ b/client/src/components/layouts/sidebar.tsx @@ -0,0 +1,165 @@ +import { Link, useLocation } from "wouter"; +import { cn } from "@/lib/utils"; +import { ThemeToggle } from "@/components/shared/theme-toggle"; +import { OrganizationSelector } from "@/components/shared/organization-selector"; +import { useAuth } from "@/hooks/use-auth"; +import { + LayoutDashboard, + Home, + Globe, + BarChart2, + FileEdit, + Key, + Users, + Settings, + CreditCard, + LogOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function Sidebar() { + const [location] = useLocation(); + const { user, logoutMutation } = useAuth(); + + // Define navigation items + const navItems = [ + { + title: "Dashboard", + href: "/", + icon: , + }, + { + title: "Domains", + href: "/domains", + icon: , + }, + { + title: "DNS Records", + href: "/dns-records", + icon: , + }, + { + title: "Metrics", + href: "/metrics", + icon: , + }, + { + title: "History", + href: "/history", + icon: , + }, + { + title: "API Tokens", + href: "/api-tokens", + icon: , + }, + ]; + + // Define admin navigation items + const adminNavItems = [ + { + title: "Users & Roles", + href: "/users-roles", + icon: , + roles: ["admin"], + }, + { + title: "Providers", + href: "/providers", + icon: , + roles: ["admin", "manager"], + }, + { + title: "Settings", + href: "/settings", + icon: , + roles: ["admin", "manager"], + }, + ]; + + // Filter admin items based on user role + const filteredAdminItems = adminNavItems.filter( + item => !item.roles || (user && item.roles.includes(user.role)) + ); + + return ( + + ); +} diff --git a/client/src/components/shared/organization-selector.tsx b/client/src/components/shared/organization-selector.tsx new file mode 100644 index 0000000..b4700a0 --- /dev/null +++ b/client/src/components/shared/organization-selector.tsx @@ -0,0 +1,63 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useOrganization } from "@/context/organization-context"; +import { ChevronDown } from "lucide-react"; + +export function OrganizationSelector() { + const { organizations, currentOrganization, setCurrentOrganization, isLoading } = useOrganization(); + + if (isLoading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + if (!currentOrganization || organizations.length === 0) { + return ( +
+
+ + No Organization +
+
+ ); + } + + return ( + + + + + + {organizations.map((org) => ( + setCurrentOrganization(org)} + className={org.id === currentOrganization.id ? "bg-muted" : ""} + > +
+ + {org.name} +
+
+ ))} +
+
+ ); +} diff --git a/client/src/components/shared/pagination.tsx b/client/src/components/shared/pagination.tsx new file mode 100644 index 0000000..7c089a6 --- /dev/null +++ b/client/src/components/shared/pagination.tsx @@ -0,0 +1,110 @@ +import { Button } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + itemLabel?: string; + totalItems?: number; + itemsPerPage?: number; +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + itemLabel = "items", + totalItems, + itemsPerPage = 10, +}: PaginationProps) { + // Generate page numbers to display + const getPageNumbers = () => { + const pages = []; + + // Always include first page + pages.push(1); + + // Add ellipsis if needed + if (currentPage > 3) { + pages.push("ellipsis1"); + } + + // Add pages around current page + for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { + if (i > 1 && i < totalPages) { + pages.push(i); + } + } + + // Add ellipsis if needed + if (currentPage < totalPages - 2) { + pages.push("ellipsis2"); + } + + // Always include last page if more than 1 page + if (totalPages > 1) { + pages.push(totalPages); + } + + return pages; + }; + + const pageNumbers = getPageNumbers(); + + const startItem = (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(startItem + itemsPerPage - 1, totalItems || 0); + + return ( +
+ {totalItems !== undefined && ( +
+ Showing {startItem}-{endItem} of {totalItems} {itemLabel} +
+ )} + +
+ + + {pageNumbers.map((page, index) => { + if (page === "ellipsis1" || page === "ellipsis2") { + return ( + + ); + } + + return ( + + ); + })} + + +
+
+ ); +} diff --git a/client/src/components/shared/theme-toggle.tsx b/client/src/components/shared/theme-toggle.tsx new file mode 100644 index 0000000..78e641e --- /dev/null +++ b/client/src/components/shared/theme-toggle.tsx @@ -0,0 +1,24 @@ +import { Button } from "@/components/ui/button"; +import { useTheme } from "@/hooks/use-theme"; +import { Moon, Sun } from "lucide-react"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const toggleTheme = () => { + setTheme(theme === "dark" ? "light" : "dark"); + }; + + return ( + + ); +} diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e6a723d --- /dev/null +++ b/client/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..8722561 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/client/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/client/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/client/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..17f14b5 --- /dev/null +++ b/client/src/components/ui/breadcrumb.tsx @@ -0,0 +1,126 @@ +import * as React from "react"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Link } from "wouter"; + +export interface BreadcrumbProps extends React.ComponentPropsWithoutRef<"nav"> { + separator?: React.ReactNode; + children?: React.ReactNode; +} + +export interface BreadcrumbItemProps extends React.ComponentPropsWithoutRef<"li"> { + children?: React.ReactNode; + isCurrent?: boolean; +} + +export interface BreadcrumbLinkProps extends React.ComponentPropsWithoutRef<"a"> { + asChild?: boolean; + children?: React.ReactNode; + href: string; +} + +export interface BreadcrumbSeparatorProps extends React.ComponentPropsWithoutRef<"li"> { + children?: React.ReactNode; +} + +export interface BreadcrumbEllipsisProps extends React.ComponentPropsWithoutRef<"li"> { + children?: React.ReactNode; +} + +const Breadcrumb = React.forwardRef( + ({ separator = , className, ...props }, ref) => { + return ( +