mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-15 01:16:38 +00:00
Merge pull request #1733 from Fredkiss3/feat-blueprint-ui-on-dashboard
feat: blueprint ui on dashboard
This commit is contained in:
208
src/components/BlueprintDetailsForm.tsx
Normal file
208
src/components/BlueprintDetailsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/components/BlueprintsTable.tsx
Normal file
206
src/components/BlueprintsTable.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
194
src/components/CreateBlueprintForm.tsx
Normal file
194
src/components/CreateBlueprintForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
41
src/components/Toploader.tsx
Normal file
41
src/components/Toploader.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user