mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-29 15:14:56 +02:00
Pull up downstream changes
This commit is contained in:
parent
c679875273
commit
98a261e38c
108 changed files with 9799 additions and 2038 deletions
499
src/components/DomainPicker.tsx
Normal file
499
src/components/DomainPicker.tsx
Normal file
|
@ -0,0 +1,499 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Building2,
|
||||
Zap,
|
||||
ArrowUpDown
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { createApiClient, formatAxiosError } from "@/lib/api";
|
||||
import { useEnvContext } from "@/hooks/useEnvContext";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type OrganizationDomain = {
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
verified: boolean;
|
||||
type: "ns" | "cname";
|
||||
};
|
||||
|
||||
type AvailableOption = {
|
||||
domainNamespaceId: string;
|
||||
fullDomain: string;
|
||||
domainId: string;
|
||||
};
|
||||
|
||||
type DomainOption = {
|
||||
id: string;
|
||||
domain: string;
|
||||
type: "organization" | "provided";
|
||||
verified?: boolean;
|
||||
domainType?: "ns" | "cname";
|
||||
domainId?: string;
|
||||
domainNamespaceId?: string;
|
||||
subdomain?: string;
|
||||
};
|
||||
|
||||
interface DomainPickerProps {
|
||||
orgId: string;
|
||||
onDomainChange?: (domainInfo: {
|
||||
domainId: string;
|
||||
domainNamespaceId?: string;
|
||||
type: "organization" | "provided";
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function DomainPicker({
|
||||
orgId,
|
||||
onDomainChange
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [userInput, setUserInput] = useState<string>("");
|
||||
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
|
||||
null
|
||||
);
|
||||
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
|
||||
[]
|
||||
);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [organizationDomains, setOrganizationDomains] = useState<
|
||||
OrganizationDomain[]
|
||||
>([]);
|
||||
const [loadingDomains, setLoadingDomains] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"all" | "organization" | "provided"
|
||||
>("all");
|
||||
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrganizationDomains = async () => {
|
||||
setLoadingDomains(true);
|
||||
try {
|
||||
const response = await api.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${orgId}/domains`);
|
||||
if (response.status === 200) {
|
||||
const domains = response.data.data.domains
|
||||
.filter(
|
||||
(domain) =>
|
||||
domain.type === "ns" || domain.type === "cname"
|
||||
)
|
||||
.map((domain) => ({
|
||||
...domain,
|
||||
type: domain.type as "ns" | "cname"
|
||||
}));
|
||||
setOrganizationDomains(domains);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load organization domains:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to load organization domains"
|
||||
});
|
||||
} finally {
|
||||
setLoadingDomains(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrganizationDomains();
|
||||
}, [orgId, api]);
|
||||
|
||||
// Generate domain options based on user input
|
||||
const generateDomainOptions = (): DomainOption[] => {
|
||||
const options: DomainOption[] = [];
|
||||
|
||||
if (!userInput.trim()) return options;
|
||||
|
||||
// Check if input is more than one level deep (contains multiple dots)
|
||||
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
|
||||
|
||||
// Add organization domain options
|
||||
organizationDomains.forEach((orgDomain) => {
|
||||
if (orgDomain.type === "cname") {
|
||||
// For CNAME domains, check if the user input matches exactly
|
||||
if (
|
||||
orgDomain.baseDomain.toLowerCase() ===
|
||||
userInput.toLowerCase()
|
||||
) {
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: orgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: "cname",
|
||||
domainId: orgDomain.domainId
|
||||
});
|
||||
}
|
||||
} else if (orgDomain.type === "ns") {
|
||||
// For NS domains, check if the user input could be a subdomain
|
||||
const userInputLower = userInput.toLowerCase();
|
||||
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
|
||||
|
||||
// Check if user input ends with the base domain
|
||||
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
|
||||
const subdomain = userInputLower.slice(
|
||||
0,
|
||||
-(baseDomainLower.length + 1)
|
||||
);
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: userInput,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: "ns",
|
||||
domainId: orgDomain.domainId,
|
||||
subdomain: subdomain
|
||||
});
|
||||
} else if (userInputLower === baseDomainLower) {
|
||||
// Exact match for base domain
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: orgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: "ns",
|
||||
domainId: orgDomain.domainId
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add provided domain options (always try to match provided domains)
|
||||
availableOptions.forEach((option) => {
|
||||
options.push({
|
||||
id: `provided-${option.domainNamespaceId}`,
|
||||
domain: option.fullDomain,
|
||||
type: "provided",
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
domainId: option.domainId,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort options
|
||||
return options.sort((a, b) => {
|
||||
const comparison = a.domain.localeCompare(b.domain);
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
};
|
||||
|
||||
const domainOptions = generateDomainOptions();
|
||||
|
||||
// Filter options based on active tab
|
||||
const filteredOptions = domainOptions.filter((option) => {
|
||||
if (activeTab === "all") return true;
|
||||
return option.type === activeTab;
|
||||
});
|
||||
|
||||
// Separate organization and provided options for pagination
|
||||
const organizationOptions = filteredOptions.filter(
|
||||
(opt) => opt.type === "organization"
|
||||
);
|
||||
const allProvidedOptions = filteredOptions.filter(
|
||||
(opt) => opt.type === "provided"
|
||||
);
|
||||
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
|
||||
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = (option: DomainOption) => {
|
||||
setSelectedOption(option);
|
||||
|
||||
if (option.type === "organization") {
|
||||
if (option.domainType === "cname") {
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
} else if (option.domainType === "ns") {
|
||||
const subdomain = option.subdomain || "";
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: subdomain || undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
}
|
||||
} else if (option.type === "provided") {
|
||||
// Extract subdomain from full domain
|
||||
const parts = option.domain.split(".");
|
||||
const subdomain = parts[0];
|
||||
const baseDomain = parts.slice(1).join(".");
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: "provided",
|
||||
subdomain: subdomain,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: baseDomain
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Domain Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain-input">
|
||||
{t("domainPickerEnterDomain")}
|
||||
</Label>
|
||||
<Input
|
||||
id="domain-input"
|
||||
value={userInput}
|
||||
onChange={(e) => {
|
||||
// Only allow letters, numbers, hyphens, and periods
|
||||
const validInput = e.target.value.replace(
|
||||
/[^a-zA-Z0-9.-]/g,
|
||||
""
|
||||
);
|
||||
setUserInput(validInput);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("domainPickerDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs and Sort Toggle */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(
|
||||
value as "all" | "organization" | "provided"
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
{t("domainPickerTabAll")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="organization">
|
||||
{t("domainPickerTabOrganization")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="provided">
|
||||
{t("domainPickerTabProvided")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||
{sortOrder === "asc"
|
||||
? t("domainPickerSortAsc")
|
||||
: t("domainPickerSortDesc")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isChecking && (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span>{t("domainPickerCheckingAvailability")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Options */}
|
||||
{!isChecking &&
|
||||
filteredOptions.length === 0 &&
|
||||
userInput.trim() && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("domainPickerNoMatchingDomains", { userInput })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Domain Options */}
|
||||
{!isChecking && filteredOptions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* Organization Domains */}
|
||||
{organizationOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("domainPickerOrganizationDomains")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{organizationOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-input",
|
||||
option.verified
|
||||
? "cursor-pointer hover:bg-accent"
|
||||
: "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={() =>
|
||||
option.verified && handleOptionSelect(option)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="font-mono text-sm">
|
||||
{option.domain}
|
||||
</p>
|
||||
{/* <Badge */}
|
||||
{/* variant={ */}
|
||||
{/* option.domainType === */}
|
||||
{/* "ns" */}
|
||||
{/* ? "default" */}
|
||||
{/* : "secondary" */}
|
||||
{/* } */}
|
||||
{/* > */}
|
||||
{/* {option.domainType} */}
|
||||
{/* </Badge> */}
|
||||
{option.verified ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
{option.subdomain && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t(
|
||||
"domainPickerSubdomain",
|
||||
{
|
||||
subdomain:
|
||||
option.subdomain
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!option.verified && (
|
||||
<p className="text-xs text-yellow-600 mt-1">
|
||||
Domain is unverified
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedOption?.id ===
|
||||
option.id && (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provided Domains */}
|
||||
{providedOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="text-sm font-medium">
|
||||
{t("domainPickerProvidedDomains")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{providedOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-input",
|
||||
"cursor-pointer hover:bg-accent"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleOptionSelect(option)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-sm">
|
||||
{option.domain}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"domainPickerNamespace",
|
||||
{
|
||||
namespace:
|
||||
option.domainNamespaceId as string
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{selectedOption?.id ===
|
||||
option.id && (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreProvided && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setProvidedDomainsShown(
|
||||
(prev) => prev + 3
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{t("domainPickerShowMore")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue