mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 05:26:32 +02:00
🚧 redirect create form
This commit is contained in:
+1
-1
@@ -4397,13 +4397,13 @@
|
||||
"redirectSettings": "Redirect Settings",
|
||||
"selectedRedirectDomain": "Selected Domain",
|
||||
"selectedRedirectResource": "Selected Resource",
|
||||
"redirectResourceNoDomain": "This resource has no domain",
|
||||
"redirectSettingsGeneralDescription": "Configure the basic redirect settings",
|
||||
"redirectSettingsDescription": "Configure where requests come from and where they are sent",
|
||||
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
|
||||
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
|
||||
"redirectAttachDomain": "Domain",
|
||||
"redirectAttachResource": "Resource",
|
||||
"redirectDomainSelect": "Select a domain",
|
||||
"redirectDomainRequired": "Select a domain to attach this redirect to",
|
||||
"redirectResourceRequired": "Select a resource to attach this redirect to",
|
||||
"redirectPermanent": "Permanent Redirect",
|
||||
|
||||
@@ -248,7 +248,7 @@ export const redirects = pgTable("redirects", {
|
||||
.$type<"exact" | "prefix" | "regex">()
|
||||
.notNull()
|
||||
.default("regex"), // exact, prefix, regex
|
||||
matchPath: varchar("matchPath").notNull().default("*"),
|
||||
matchPath: varchar("matchPath").notNull().default(".*"),
|
||||
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
|
||||
// else, the original path will be kept
|
||||
rewritePathType: varchar("rewritePathType").$type<
|
||||
|
||||
@@ -258,6 +258,7 @@ export const redirects = sqliteTable("redirects", {
|
||||
}),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain"),
|
||||
destinationDomain: text("destinationDomain").notNull(),
|
||||
pathMatchType: text("pathMatchType")
|
||||
.$type<"exact" | "prefix" | "regex">()
|
||||
|
||||
@@ -29,6 +29,7 @@ const bodySchema = z.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional().nullable(),
|
||||
domainId: z.string().nonempty().optional().nullable(),
|
||||
subdomain: z.string().nonempty().optional().nullable(),
|
||||
destinationDomain: z.string().nonempty(),
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema,
|
||||
@@ -103,6 +104,7 @@ export async function createRedirect(
|
||||
name,
|
||||
resourceId,
|
||||
domainId,
|
||||
subdomain,
|
||||
destinationDomain,
|
||||
pathMatchType,
|
||||
matchPath,
|
||||
@@ -170,6 +172,7 @@ export async function createRedirect(
|
||||
niceId,
|
||||
resourceId: resourceId ?? null,
|
||||
domainId: domainId ?? null,
|
||||
subdomain: subdomain ?? null,
|
||||
destinationDomain,
|
||||
pathMatchType: pathMatchType ?? "regex",
|
||||
matchPath,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type GetRedirectResponse = {
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationDomain: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string;
|
||||
@@ -39,6 +40,7 @@ const redirectColumns = {
|
||||
orgId: redirects.orgId,
|
||||
niceId: redirects.niceId,
|
||||
name: redirects.name,
|
||||
subdomain: redirects.subdomain,
|
||||
destinationDomain: redirects.destinationDomain,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
matchPath: redirects.matchPath,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ListRedirectsResponse = PaginatedResponse<{
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationDomain: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string;
|
||||
@@ -137,6 +138,7 @@ export async function listRedirects(
|
||||
orgId: redirects.orgId,
|
||||
niceId: redirects.niceId,
|
||||
name: redirects.name,
|
||||
subdomain: redirects.subdomain,
|
||||
destinationDomain: redirects.destinationDomain,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
matchPath: redirects.matchPath,
|
||||
|
||||
@@ -31,6 +31,7 @@ const bodySchema = z.strictObject({
|
||||
niceId: redirectNiceIdSchema.optional(),
|
||||
resourceId: z.number().int().positive().optional().nullable(),
|
||||
domainId: z.string().nonempty().optional().nullable(),
|
||||
subdomain: z.string().nonempty().optional().nullable(),
|
||||
destinationDomain: z.string().nonempty().optional(),
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema.optional(),
|
||||
@@ -196,6 +197,9 @@ export async function updateRedirect(
|
||||
if (body.domainId !== undefined) {
|
||||
updateData.domainId = body.domainId;
|
||||
}
|
||||
if (body.subdomain !== undefined) {
|
||||
updateData.subdomain = body.subdomain;
|
||||
}
|
||||
if (body.destinationDomain !== undefined) {
|
||||
updateData.destinationDomain = body.destinationDomain;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
||||
redirectId: redirect.redirectId,
|
||||
niceId: redirect.niceId,
|
||||
name: redirect.name,
|
||||
subdomain: redirect.subdomain,
|
||||
destinationDomain: redirect.destinationDomain,
|
||||
pathMatchType: redirect.pathMatchType,
|
||||
matchPath: redirect.matchPath,
|
||||
|
||||
@@ -41,7 +41,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type {
|
||||
@@ -49,7 +48,6 @@ import type {
|
||||
GetRedirectResponse
|
||||
} from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -63,9 +61,10 @@ import {
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { Plus } from "lucide-react";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import Link from "next/link";
|
||||
|
||||
const DEFAULT_MATCH_PATH = "*";
|
||||
const DEFAULT_MATCH_PATH = ".*";
|
||||
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
|
||||
|
||||
export type ExistingRedirect = GetRedirectResponse["redirect"];
|
||||
@@ -95,8 +94,6 @@ export default function RedirectForm({
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<SelectedResource | null>(initialResource);
|
||||
|
||||
const { data: domains = [] } = useQuery(orgQueries.domains({ orgId }));
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
@@ -107,6 +104,7 @@ export default function RedirectForm({
|
||||
.min(1, { message: t("nameRequired") }),
|
||||
attachTo: z.enum(["domain", "resource"]),
|
||||
domainId: z.string().nullable(),
|
||||
subdomain: z.string().nullable(),
|
||||
resourceId: z.number().int().positive().nullable(),
|
||||
destinationDomain: z
|
||||
.string()
|
||||
@@ -163,6 +161,7 @@ export default function RedirectForm({
|
||||
name: redirect?.name ?? "",
|
||||
attachTo: redirect?.resourceId ? "resource" : "domain",
|
||||
domainId: redirect?.domainId ?? null,
|
||||
subdomain: redirect?.subdomain ?? null,
|
||||
resourceId: redirect?.resourceId ?? null,
|
||||
destinationDomain: redirect?.destinationDomain ?? "",
|
||||
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
|
||||
@@ -190,6 +189,8 @@ export default function RedirectForm({
|
||||
const body = {
|
||||
name: values.name.trim(),
|
||||
domainId: values.attachTo === "domain" ? values.domainId : null,
|
||||
subdomain:
|
||||
values.attachTo === "domain" ? values.subdomain || null : null,
|
||||
resourceId:
|
||||
values.attachTo === "resource" ? values.resourceId : null,
|
||||
destinationDomain: values.destinationDomain.trim(),
|
||||
@@ -283,7 +284,7 @@ export default function RedirectForm({
|
||||
)}
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("general")}
|
||||
@@ -301,6 +302,35 @@ export default function RedirectForm({
|
||||
id="redirect-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-enabled"
|
||||
label={t(
|
||||
"enabled"
|
||||
)}
|
||||
description={t(
|
||||
"redirectEnabledDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -368,56 +398,41 @@ export default function RedirectForm({
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{attachTo === "domain" ? (
|
||||
<SettingsFormCell span="half">
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={({ field }) => (
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"selectedRedirectDomain"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
field.value ??
|
||||
undefined
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={1}
|
||||
hideFreeDomain
|
||||
defaultDomainId={
|
||||
redirect?.domainId
|
||||
}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
allowWildcard
|
||||
defaultSubdomain={
|
||||
redirect?.subdomain
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"redirectDomainSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{domains.map(
|
||||
(
|
||||
domain
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
domain.domainId
|
||||
}
|
||||
value={
|
||||
domain.domainId
|
||||
}
|
||||
>
|
||||
{
|
||||
domain.baseDomain
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res?.domainId ??
|
||||
null,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
res?.subdomain ||
|
||||
null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -484,6 +499,33 @@ export default function RedirectForm({
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{attachTo === "resource" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourceDomain")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
disabled
|
||||
readOnly
|
||||
value={
|
||||
selectedResource?.fullDomain ??
|
||||
""
|
||||
}
|
||||
placeholder={
|
||||
selectedResource
|
||||
? t(
|
||||
"redirectResourceNoDomain"
|
||||
)
|
||||
: t(
|
||||
"resourceSelect"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -491,7 +533,7 @@ export default function RedirectForm({
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("redirectSettings")}
|
||||
@@ -504,33 +546,9 @@ export default function RedirectForm({
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="redirect-form"
|
||||
>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationDomain"
|
||||
|
||||
@@ -30,6 +30,7 @@ export type RedirectRow = {
|
||||
redirectId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationDomain: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string;
|
||||
@@ -200,8 +201,14 @@ export default function RedirectsTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
const host =
|
||||
redirect.resourceFullDomain ?? redirect.baseDomain;
|
||||
// A domain-attached redirect may target a specific host
|
||||
// under the base domain, e.g. old.example.com.
|
||||
const domainHost = redirect.baseDomain
|
||||
? [redirect.subdomain, redirect.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".")
|
||||
: null;
|
||||
const host = redirect.resourceFullDomain ?? domainHost;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -237,7 +244,13 @@ export default function RedirectsTable({
|
||||
}
|
||||
|
||||
if (redirect.baseDomain) {
|
||||
return <span>{redirect.baseDomain}</span>;
|
||||
return (
|
||||
<span>
|
||||
{[redirect.subdomain, redirect.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>-</span>;
|
||||
|
||||
+67
-15
@@ -1,23 +1,46 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
export function SettingsContainer({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-6", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
export function SettingsSection({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-lg bg-card p-5 flex flex-col min-h-[200px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
|
||||
return (
|
||||
<div className={cn("text-lg space-y-0.5 pb-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({
|
||||
@@ -87,23 +110,36 @@ export function SettingsFormCell({
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-1xl font-semibold tracking-tight flex items-center gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionDescription({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||
return (
|
||||
<p className={cn("text-muted-foreground text-sm", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSubsectionHeader({
|
||||
@@ -141,11 +177,15 @@ export function SettingsSubsectionDescription({
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className="space-y-5 flex-grow">{children}</div>;
|
||||
return (
|
||||
<div className={cn("space-y-5 flex-grow", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
@@ -169,10 +209,22 @@ export function SettingsSectionFooter({
|
||||
|
||||
export function SettingsSectionGrid({
|
||||
children,
|
||||
cols
|
||||
cols = 4,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols: number;
|
||||
cols?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// @ts-expect-error
|
||||
"--cols": `repeat(${cols}, minmax(0, 1fr))`
|
||||
}}
|
||||
className={cn(`grid md:grid-cols-(--cols) gap-6`, className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user