mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-10 09:33:15 +00:00
many minor visual improvements
This commit is contained in:
@@ -101,6 +101,8 @@
|
||||
"sitesTableViewPrivateResources": "View Private Resources",
|
||||
"siteInstallNewt": "Install Site",
|
||||
"siteInstallNewtDescription": "Install the site connector for your system",
|
||||
"siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
|
||||
"siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
|
||||
"WgConfiguration": "WireGuard Configuration",
|
||||
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
||||
"operatingSystem": "Operating System",
|
||||
@@ -1220,8 +1222,10 @@
|
||||
"addLabels": "Add labels",
|
||||
"siteLabelsTab": "Labels",
|
||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||
"labelsNotFound": "Labels not found",
|
||||
"labelsNotFound": "No labels found.",
|
||||
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
||||
"labelSearch": "Search labels",
|
||||
"labelSearchOrCreate": "Search or create a label",
|
||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||
"accessLabelFilterClear": "Clear label filters",
|
||||
@@ -2073,7 +2077,7 @@
|
||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||
"sshDaemonPort": "Daemon Port",
|
||||
"sshServerDestination": "Server Destination",
|
||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
||||
"sshServerDestinationDescription": "Configure the destination of the SSH server",
|
||||
"destination": "Destination",
|
||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||
"roleAllowSsh": "Allow SSH",
|
||||
|
||||
@@ -334,19 +334,15 @@ export function ProxyResourceTargetsForm({
|
||||
{row.original.siteType === "newt" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 w-full text-left cursor-pointer"
|
||||
className="flex items-center space-x-2 w-full text-left cursor-pointer"
|
||||
onClick={() =>
|
||||
openHealthCheckDialog(row.original)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
|
||||
></div>
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
|
||||
></div>
|
||||
<span>{getStatusText(status)}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
@@ -535,7 +531,7 @@ export function ProxyResourceTargetsForm({
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
@@ -554,9 +550,8 @@ export function ProxyResourceTargetsForm({
|
||||
|
||||
const actionsColumn: ColumnDef<LocalTarget> = {
|
||||
id: "actions",
|
||||
header: () => <span className="p-3">{t("actions")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => removeTarget(row.original.targetId)}
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
@@ -386,9 +389,9 @@ function SshServerForm({
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
@@ -397,9 +400,9 @@ function SshServerForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
@@ -410,9 +413,9 @@ function SshServerForm({
|
||||
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
@@ -460,14 +463,14 @@ function SshServerForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
{isNative ? (
|
||||
<Popover
|
||||
open={nativeSiteOpen}
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
@@ -711,7 +714,7 @@ export default function Page() {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
|
||||
id="base-resource-form"
|
||||
>
|
||||
<FormField
|
||||
@@ -794,7 +797,7 @@ export default function Page() {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
|
||||
id="tcp-udp-settings-form"
|
||||
>
|
||||
<FormField
|
||||
@@ -879,10 +882,10 @@ export default function Page() {
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
{/* Mode */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<
|
||||
"standard" | "native"
|
||||
>
|
||||
@@ -893,12 +896,12 @@ export default function Page() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"sshAuthenticationMethod"
|
||||
)}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<
|
||||
"passthrough" | "push"
|
||||
>
|
||||
@@ -913,12 +916,12 @@ export default function Page() {
|
||||
|
||||
{/* Daemon Location (standard + push) */}
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"sshAuthDaemonLocation"
|
||||
)}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<
|
||||
"site" | "remote"
|
||||
>
|
||||
@@ -953,49 +956,55 @@ export default function Page() {
|
||||
{/* Daemon Port (standard + push + remote) */}
|
||||
{showDaemonPort && (
|
||||
<Form {...sshDaemonPortForm}>
|
||||
<FormField
|
||||
control={
|
||||
sshDaemonPortForm.control
|
||||
}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"sshDaemonPort"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={
|
||||
65535
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full md:w-1/2">
|
||||
<FormField
|
||||
control={
|
||||
sshDaemonPortForm.control
|
||||
}
|
||||
name="authDaemonPort"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"sshDaemonPort"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={
|
||||
1
|
||||
}
|
||||
max={
|
||||
65535
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Server Destination */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"sshServerDestination"
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(
|
||||
"sshServerDestinationDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
{isNative ? (
|
||||
<Popover
|
||||
open={nativeSiteOpen}
|
||||
@@ -1007,7 +1016,7 @@ export default function Page() {
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full max-w-xs justify-between font-normal"
|
||||
className="w-full md:w-1/2 justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{nativeSelectedSite?.name ??
|
||||
|
||||
@@ -256,7 +256,7 @@ export default function GeneralPage() {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="">
|
||||
<SwitchInput
|
||||
id="auto-update-enabled"
|
||||
label={t(
|
||||
@@ -285,7 +285,7 @@ export default function GeneralPage() {
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 pb-2 text-xs"
|
||||
className="text-sm text-muted-foreground underline px-0"
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
|
||||
@@ -243,10 +243,8 @@ export default function Page() {
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue("autoProvision", checked);
|
||||
}}
|
||||
description={t("idpAutoProvisionConfigureAfterCreate")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionConfigureAfterCreate")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.88 0.004 286.32);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.88 0.004 286.32);
|
||||
--ring: oklch(0.705 0.213 47.604);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
@@ -57,7 +57,7 @@
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.5382 0.1949 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 15%);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.646 0.222 41.116);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
|
||||
@@ -105,7 +105,6 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||
{t("destination")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="192.168.1.1"
|
||||
value={props.destination}
|
||||
onChange={(e) =>
|
||||
props.onDestinationChange(e.target.value)
|
||||
@@ -116,7 +115,6 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||
<label className="text-sm font-semibold">{t("port")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={props.defaultPort.toString()}
|
||||
value={props.destinationPort}
|
||||
onChange={(e) =>
|
||||
props.onDestinationPortChange(e.target.value)
|
||||
|
||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn(
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100dvh-clamp(1.5rem,12vh,200px)-1.5rem)] md:translate-y-0 md:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import {
|
||||
OptionSelect,
|
||||
@@ -1807,10 +1812,10 @@ export function PrivateResourceForm({
|
||||
/>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<"standard" | "native">
|
||||
value={sshServerMode}
|
||||
options={[
|
||||
@@ -1864,10 +1869,10 @@ export function PrivateResourceForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pamMode"
|
||||
@@ -1959,10 +1964,10 @@ export function PrivateResourceForm({
|
||||
|
||||
{/* Daemon Location (standard + push) */}
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
</SettingsSubsectionTitle>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonMode"
|
||||
@@ -2062,51 +2067,57 @@ export function PrivateResourceForm({
|
||||
|
||||
{/* Daemon Port (standard + push + remote) */}
|
||||
{showDaemonPort && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="22123"
|
||||
disabled={
|
||||
sshSectionDisabled
|
||||
}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
if (sshSectionDisabled)
|
||||
return;
|
||||
const v =
|
||||
e.target.value;
|
||||
if (v === "") {
|
||||
field.onChange(
|
||||
null
|
||||
);
|
||||
return;
|
||||
<div className="w-full md:w-1/2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="22123"
|
||||
disabled={
|
||||
sshSectionDisabled
|
||||
}
|
||||
const num = parseInt(
|
||||
v,
|
||||
10
|
||||
);
|
||||
field.onChange(
|
||||
Number.isNaN(num)
|
||||
? null
|
||||
: num
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
value={
|
||||
field.value ?? ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
sshSectionDisabled
|
||||
)
|
||||
return;
|
||||
const v =
|
||||
e.target.value;
|
||||
if (v === "") {
|
||||
field.onChange(
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
const num =
|
||||
parseInt(v, 10);
|
||||
field.onChange(
|
||||
Number.isNaN(
|
||||
num
|
||||
)
|
||||
? null
|
||||
: num
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -823,7 +823,7 @@ function TargetStatusCell({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-2 font-normal"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
>
|
||||
<StatusIcon status={overallStatus} />
|
||||
<span className="text-sm">
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function SetResourceHeaderAuthForm({
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
|
||||
@@ -63,6 +63,42 @@ export function SettingsSectionDescription({
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionHeader({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-0.5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionTitle({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3 className={cn("text-sm font-semibold", className)}>{children}</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSubsectionDescription({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({
|
||||
children
|
||||
}: {
|
||||
|
||||
@@ -43,8 +43,8 @@ export function SwitchInput({
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useActionState, useMemo, useRef, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
@@ -88,6 +88,11 @@ export function LabelsSelector({
|
||||
colorValues[Math.floor(Math.random() * colorValues.length)];
|
||||
|
||||
const [, action, isPending] = useActionState(createLabel, null);
|
||||
const createFormRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const trimmedQuery = labelSearchQuery.trim();
|
||||
const canCreateLabel =
|
||||
trimmedQuery.length > 0 && labelsShown.length === 0 && !isPending;
|
||||
|
||||
async function createLabel(_: any, formData: FormData) {
|
||||
const name = formData.get("name")?.toString();
|
||||
@@ -120,21 +125,28 @@ export function LabelsSelector({
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("labelSearch")}
|
||||
placeholder={t("labelSearchOrCreate")}
|
||||
value={labelSearchQuery}
|
||||
onValueChange={setlabelsSearchQuery}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && canCreateLabel) {
|
||||
e.preventDefault();
|
||||
createFormRef.current?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="px-3 break-all wrap-anywhere text-wrap">
|
||||
<CommandEmpty className="px-3 py-6 text-center text-wrap">
|
||||
{labelSearchQuery.trim().length > 0 ? (
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<span className="max-w-34">
|
||||
<span className="max-w-34 break-words">
|
||||
{t("createNewLabel", {
|
||||
label: labelSearchQuery.trim()
|
||||
})}
|
||||
</span>
|
||||
|
||||
<form
|
||||
ref={createFormRef}
|
||||
action={action}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -159,14 +171,17 @@ export function LabelsSelector({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="size-4 rounded-full bg-(--color) flex-none"
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": value
|
||||
}}
|
||||
/>
|
||||
<span data-name>
|
||||
{color}
|
||||
{color
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
color.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
@@ -176,7 +191,6 @@ export function LabelsSelector({
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isPending}
|
||||
type="submit"
|
||||
>
|
||||
@@ -185,7 +199,14 @@ export function LabelsSelector({
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
t("labelsNotFound")
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<span className="text-muted-foreground">
|
||||
{t("labelsNotFound")}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{t("labelsEmptyCreateHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
FaLinux,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
@@ -333,31 +334,36 @@ WantedBy=default.target`
|
||||
<p className="font-semibold mb-3">{t("commands")}</p>
|
||||
{platform === "kubernetes" && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
For more and up to date Kubernetes installation
|
||||
information, see{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
docs.pangolin.net/manage/sites/install-kubernetes
|
||||
</a>
|
||||
.
|
||||
{t.rich("siteInstallKubernetesDocsDescription", {
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{platform === "advantech" && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
For Advantech modem installation instructions, see{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-advantech"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
docs.pangolin.net/manage/sites/install-advantech
|
||||
</a>
|
||||
.
|
||||
{t.rich("siteInstallAdvantechDocsDescription", {
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-advantech"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 space-y-3">
|
||||
|
||||
@@ -385,7 +385,7 @@ export function CreatePolicyAuthMethodsSectionForm({
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
@@ -426,7 +426,10 @@ export function CreatePolicyAuthMethodsSectionForm({
|
||||
{/* Password row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
|
||||
className={cn(
|
||||
"flex items-center text-sm space-x-2",
|
||||
password && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
@@ -456,7 +459,10 @@ export function CreatePolicyAuthMethodsSectionForm({
|
||||
{/* Pincode row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 text-sm",
|
||||
pincode && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
@@ -484,7 +490,10 @@ export function CreatePolicyAuthMethodsSectionForm({
|
||||
{/* Header auth row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 text-sm",
|
||||
headerAuth && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Bot size="14" />
|
||||
<span>
|
||||
|
||||
@@ -491,7 +491,7 @@ export function EditPolicyAuthMethodsSectionForm({
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
|
||||
@@ -670,7 +670,7 @@ export function PolicyAuthMethodsSection({
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
|
||||
Reference in New Issue
Block a user