Merge pull request #1733 from Fredkiss3/feat-blueprint-ui-on-dashboard

feat: blueprint ui on dashboard
This commit is contained in:
Owen Schwartz
2025-10-29 20:45:31 -07:00
committed by GitHub
45 changed files with 1695 additions and 142 deletions

View File

@@ -0,0 +1,58 @@
import BlueprintDetailsForm from "@app/components/BlueprintDetailsForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { GetBlueprintResponse } from "@server/routers/blueprints";
import { AxiosResponse } from "axios";
import { ArrowLeft } from "lucide-react";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
type BluePrintsPageProps = {
params: Promise<{ orgId: string; blueprintId: string }>;
};
export const metadata: Metadata = {
title: "Blueprint Detail"
};
export default async function BluePrintDetailPage(props: BluePrintsPageProps) {
const params = await props.params;
let org = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);
}
let blueprint = null;
try {
const res = await internal.get<AxiosResponse<GetBlueprintResponse>>(
`/org/${params.orgId}/blueprint/${params.blueprintId}`,
await authCookieHeader()
);
blueprint = res.data.data;
} catch (e) {
console.error(e);
notFound();
}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("blueprintDetails")}
description={t("blueprintDetailsDescription")}
/>
<BlueprintDetailsForm blueprint={blueprint} />
</>
);
}

View File

@@ -0,0 +1,42 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowLeft } from "lucide-react";
import CreateBlueprintForm from "@app/components/CreateBlueprintForm";
export interface CreateBlueprintPageProps {
params: Promise<{ orgId: string }>;
}
export const metadata: Metadata = {
title: "Create blueprint"
};
export default async function CreateBlueprintPage(
props: CreateBlueprintPageProps
) {
const t = await getTranslations();
const orgId = (await props.params).orgId;
return (
<>
<div className="flex gap-2 justify-between">
<SettingsSectionTitle
title={t("blueprintCreate")}
description={t("blueprintCreateDescription2")}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/blueprints`}>
{t("blueprintGoBack")}
</Link>
</Button>
</div>
<CreateBlueprintForm orgId={orgId} />
</>
);
}

View File

@@ -0,0 +1,57 @@
import BlueprintsTable, {
type BlueprintRow
} from "@app/components/BlueprintsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import { ListBlueprintsResponse } from "@server/routers/blueprints";
import { AxiosResponse } from "axios";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
type BluePrintsPageProps = {
params: Promise<{ orgId: string }>;
};
export const metadata: Metadata = {
title: "Blueprints"
};
export default async function BluePrintsPage(props: BluePrintsPageProps) {
const params = await props.params;
let blueprints: BlueprintRow[] = [];
try {
const res = await internal.get<AxiosResponse<ListBlueprintsResponse>>(
`/org/${params.orgId}/blueprints`,
await authCookieHeader()
);
blueprints = res.data.data.blueprints;
} catch (e) {
console.error(e);
}
let org = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);
}
const t = await getTranslations();
return (
<OrgProvider org={org}>
<SettingsSectionTitle
title={t("blueprints")}
description={t("blueprintsDescription")}
/>
<BlueprintsTable blueprints={blueprints} orgId={params.orgId} />
</OrgProvider>
);
}

View File

@@ -9,7 +9,8 @@ import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
import { toUnicode } from 'punycode';
import { toUnicode } from "punycode";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type Props = {
params: Promise<{ orgId: string }>;
@@ -20,15 +21,16 @@ export default async function DomainsPage(props: Props) {
let domains: DomainRow[] = [];
try {
const res = await internal.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
const res = await internal.get<AxiosResponse<ListDomainsResponse>>(
`/org/${params.orgId}/domains`,
await authCookieHeader()
);
const rawDomains = res.data.data.domains as DomainRow[];
domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
baseDomain: toUnicode(domain.baseDomain)
}));
} catch (e) {
console.error(e);
@@ -36,21 +38,12 @@ export default async function DomainsPage(props: Props) {
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);
}
if (!org) {
}
const t = await getTranslations();
return (

View File

@@ -1,25 +1,15 @@
import { Metadata } from "next";
import {
Combine,
KeyRound,
LinkIcon,
Settings,
Users,
Waypoints,
Workflow
} from "lucide-react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { ListUserOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation";
@@ -27,7 +17,10 @@ import { orgNavSections } from "@app/app/navigation";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
title: {
template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
default: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}`
},
description: ""
};
@@ -86,7 +79,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<UserProvider user={user}>
<Layout orgId={params.orgId} orgs={orgs} navItems={orgNavSections(env.flags.enableClients)}>
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env.flags.enableClients)}
>
{children}
</Layout>
</UserProvider>

View File

@@ -0,0 +1,17 @@
import { getTranslations } from "next-intl/server";
export default async function NotFound() {
const t = await getTranslations();
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>
<p className="text-neutral-500 dark:text-neutral-700 mb-8">
{t("pageNotFoundDescription")}
</p>
</div>
);
}

View File

@@ -40,7 +40,7 @@
}
.dark {
--background: oklch(0.20 0.006 285.885);
--background: oklch(0.2 0.006 285.885);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
@@ -140,3 +140,7 @@ p {
word-break: keep-all;
white-space: normal;
}
#nprogress .bar {
background: var(--color-primary) !important;
}

View File

@@ -18,6 +18,7 @@ import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
import { build } from "@server/build";
import { TopLoader } from "@app/components/Toploader";
import Script from "next/script";
export const metadata: Metadata = {
@@ -85,6 +86,7 @@ export default async function RootLayout({
return (
<html suppressHydrationWarning lang={locale}>
<body className={`${font.className} h-screen overflow-hidden`}>
<TopLoader />
{build === "saas" && (
<Script
src="https://rybbit.fossorial.io/api/script.js"

View File

@@ -1,21 +1,19 @@
import { SidebarNavItem } from "@app/components/SidebarNav";
import { build } from "@server/build";
import {
Home,
Settings,
Users,
Link as LinkIcon,
Waypoints,
Combine,
Fingerprint,
Workflow,
KeyRound,
TicketCheck,
User,
Globe, // Added from 'dev' branch
MonitorUp, // Added from 'dev' branch
Server,
Zap,
ReceiptText,
CreditCard,
Logs,
SquareMousePointer,
@@ -77,6 +75,11 @@ export const orgNavSections = (
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="h-4 w-4" />
}
]
},

View File

@@ -0,0 +1,208 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useForm } from "react-hook-form";
import { Input } from "./ui/input";
import Editor from "@monaco-editor/react";
import { cn } from "@app/lib/cn";
import type { GetBlueprintResponse } from "@server/routers/blueprints";
import { Alert, AlertDescription } from "./ui/alert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "./InfoSection";
import { Badge } from "./ui/badge";
import { Globe, Terminal, Webhook } from "lucide-react";
export type CreateBlueprintFormProps = {
blueprint: GetBlueprintResponse;
};
export default function BlueprintDetailsForm({
blueprint
}: CreateBlueprintFormProps) {
const t = useTranslations();
const form = useForm({
disabled: true,
defaultValues: {
name: blueprint.name,
contents: blueprint.contents
}
});
return (
<Form {...form}>
<div className="flex flex-col gap-6">
<Alert>
<AlertDescription>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("appliedAt")}
</InfoSectionTitle>
<InfoSectionContent>
<time
className="text-muted-foreground"
dateTime={blueprint.createdAt.toString()}
>
{new Date(
blueprint.createdAt * 1000
).toLocaleString()}
</time>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{blueprint.succeeded ? (
<Badge variant="green">
{t("success")}
</Badge>
) : (
<Badge variant="red">
{t("failed", {
fallback: "Failed"
})}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("message")}
</InfoSectionTitle>
<InfoSectionContent>
<p className="text-muted-foreground">
{blueprint.message}
</p>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("source")}
</InfoSectionTitle>
<InfoSectionContent>
{blueprint.source === "API" && (
<Badge
variant="secondary"
className="-mx-2"
>
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
</Badge>
)}
{blueprint.source === "NEWT" && (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
</Badge>
)}
{blueprint.source === "UI" && (
<Badge
variant="secondary"
className="-mx-1 py-1"
>
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
</Badge>
)}{" "}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("blueprintInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("parsedContents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
},
readOnly: true
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
</div>
</Form>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
Globe,
Terminal,
Webhook
} from "lucide-react";
import { useTransition } from "react";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { DataTable } from "./ui/data-table";
import Link from "next/link";
import { ListBlueprintsResponse } from "@server/routers/blueprints";
export type BlueprintRow = ListBlueprintsResponse["blueprints"][number];
type Props = {
blueprints: BlueprintRow[];
orgId: string;
};
export default function BlueprintsTable({ blueprints, orgId }: Props) {
const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();
const router = useRouter();
const columns: ColumnDef<BlueprintRow>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("appliedAt")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<time
className="text-muted-foreground"
dateTime={row.original.createdAt.toString()}
>
{new Date(
row.original.createdAt * 1000
).toLocaleString()}
</time>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
}
},
{
accessorKey: "source",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("source")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
switch (originalRow.source) {
case "API": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
</Badge>
);
}
case "NEWT": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
</Badge>
);
}
case "UI": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
</Badge>
);
}
}
}
},
{
accessorKey: "succeeded",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { succeeded } = row.original;
if (succeeded) {
return <Badge variant="green">{t("success")}</Badge>;
} else {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
}
}
},
{
id: "actions",
header: () => {
return null;
},
cell: ({ row }) => {
return (
<div className="flex justify-end">
<Button
variant="outline"
className="items-center"
asChild
>
<Link
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
>
View details{" "}
<ArrowRight className="size-4 flex-none" />
</Link>
</Button>
</div>
);
}
}
];
return (
<DataTable
columns={columns}
data={blueprints}
persistPageSize="blueprint-table"
title={t("blueprints")}
searchPlaceholder={t("searchBlueprintProgress")}
searchColumn="name"
onAdd={() => {
router.push(`/${orgId}/settings/blueprints/create`);
}}
addButtonText={t("blueprintAdd")}
onRefresh={() => {
startTransition(() => router.refresh());
}}
isRefreshing={isRefreshing}
defaultSort={{
id: "createdAt",
desc: true
}}
/>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { useForm } from "react-hook-form";
import { Input } from "./ui/input";
import { useActionState } from "react";
import Editor from "@monaco-editor/react";
import { cn } from "@app/lib/cn";
import { Button } from "./ui/button";
import { parse as parseYaml } from "yaml";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import type { CreateBlueprintResponse } from "@server/routers/blueprints";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
export type CreateBlueprintFormProps = {
orgId: string;
};
export default function CreateBlueprintForm({
orgId
}: CreateBlueprintFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const router = useRouter();
const form = useForm({
resolver: zodResolver(
z.object({
name: z.string().min(1).max(255),
contents: z
.string()
.min(1)
.superRefine((contents, ctx) => {
try {
parseYaml(contents);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
})
})
),
defaultValues: {
name: "",
contents: `# Example blueprint
# proxy-resources:
# resource-nice-id-uno:
# name: this is my resource
# protocol: http
# full-domain: never-gonna-give-you-up.example.com
# targets:
# - site: lively-yosemite-toad
# hostname: localhost
# method: http
# port: 8000
`
}
});
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const res = await api
.put<AxiosResponse<CreateBlueprintResponse>>(
`/org/${orgId}/blueprint/`,
{
name: form.getValues("name"),
blueprint: form.getValues("contents")
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorCreate"),
description: formatAxiosError(
e,
t("blueprintErrorCreateDescription")
)
});
});
if (res && res.status === 201) {
const createdBlueprint = res.data.data;
toast({
variant: "warning",
title: createdBlueprint.succeeded ? "Success" : "Warning",
description: createdBlueprint.message
});
router.push(`/${orgId}/settings/blueprints`);
}
}
return (
<Form {...form}>
<form action={formAction} id="base-resource-form">
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("blueprintInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
}
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<div className="flex justify-end space-x-2 mt-8">
<Button type="submit" loading={isSubmitting}>
{t("actionApplyBlueprint")}
</Button>
</div>
</SettingsContainer>
</form>
</Form>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols
@@ -9,25 +11,44 @@ export function InfoSections({
}) {
return (
<div
className={`grid md:grid-cols-${cols || 1} md:gap-4 gap-2 md:items-start grid-cols-1`}
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value
"--columns": `repeat(${cols || 1}, minmax(0, 1fr))`
}}
>
{children}
</div>
);
}
export function InfoSection({ children }: { children: React.ReactNode }) {
return <div className="space-y-1">{children}</div>;
export function InfoSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-1", className)}>{children}</div>;
}
export function InfoSectionTitle({ children }: { children: React.ReactNode }) {
return <div className="font-semibold">{children}</div>;
export function InfoSectionTitle({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("font-semibold", className)}>{children}</div>;
}
export function InfoSectionContent({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="break-words">{children}</div>;
return <div className={cn("break-words", className)}>{children}</div>;
}

View File

@@ -11,7 +11,6 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import CertificateStatus from "@app/components/private/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";

View File

@@ -1,9 +1,15 @@
import { cn } from "@app/lib/cn";
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
return (
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
{children}
</div>
);
}
export function SettingsSectionHeader({
@@ -15,11 +21,15 @@ export function SettingsSectionHeader({
}
export function SettingsSectionForm({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="max-w-xl space-y-4">{children}</div>;
return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
);
}
export function SettingsSectionTitle({
@@ -55,7 +65,11 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
return (
<div className="flex justify-end space-x-2 mt-auto pt-6">
{children}
</div>
);
}
export function SettingsSectionGrid({

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as NProgress from "nprogress";
import NextTopLoader from "nextjs-toploader";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function TopLoader() {
return (
<>
<NextTopLoader showSpinner={false} />
<FinishingLoader />
</>
);
}
function FinishingLoader() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
React.useEffect(() => {
NProgress.done();
}, [pathname, router, searchParams]);
React.useEffect(() => {
const linkClickListener = (ev: MouseEvent) => {
const element = ev.target as HTMLElement;
const closestlink = element.closest("a");
const isOpenToNewTabClick =
ev.ctrlKey ||
ev.shiftKey ||
ev.metaKey || // apple
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
if (closestlink && isOpenToNewTabClick) {
NProgress.done();
}
};
window.addEventListener("click", linkClickListener);
return () => window.removeEventListener("click", linkClickListener);
}, []);
return null;
}

View File

@@ -71,8 +71,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={loading || props.disabled} // Disable button when loading
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{props.children}
{asChild ? (
props.children
) : (
<>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{props.children}
</>
)}
</Comp>
);
}

View File

@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View File

@@ -31,7 +31,8 @@ const toastVariants = cva(
variant: {
default: "border bg-card text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground"
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground",
warning: "group border-amber-600 bg-amber-600 text-white"
}
},
defaultVariants: {

View File

@@ -0,0 +1,12 @@
import type { GetOrgResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import { cache } from "react";
import { authCookieHeader } from "./cookies";
import { internal } from ".";
export const getCachedOrg = cache(async (orgId: string) =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);

4
src/lib/wait.ts Normal file
View File

@@ -0,0 +1,4 @@
export function wait(ms: number): Promise<void> {
// Wait for the specified amount of time
return new Promise((resolve) => setTimeout(resolve, ms));
}