more visual enhancements and update readme
51
README.md
|
@ -25,6 +25,10 @@ _Your own self-hosted zero trust tunnel._
|
|||
<a href="https://docs.fossorial.io">
|
||||
Full Documentation
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="mailto:numbat@fossorial.io">
|
||||
Contact Us
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
|
@ -68,41 +72,17 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||
### Easy Deployment
|
||||
|
||||
- Run on any cloud provider or on-premises.
|
||||
- Docker Compose based setup for simplified deployment.
|
||||
- **Docker Compose based setup** for simplified deployment.
|
||||
- Future-proof installation script for streamlined setup and feature additions.
|
||||
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
||||
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
|
||||
|
||||
### Modular Design
|
||||
|
||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
|
||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
|
||||
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
||||
- Attach as many sites to the central server as you wish.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/screenshots/sites.png" alt="Sites Example" width="200"/></td>
|
||||
<td align="center"><img src="public/screenshots/users.png" alt="Users Example" width="200"/></td>
|
||||
<td align="center"><img src="public/screenshots/share-link.png" alt="Share Link Example" width="200"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Sites</b></td>
|
||||
<td align="center"><b>Users</b></td>
|
||||
<td align="center"><b>Share Link</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/screenshots/auth.png" alt="Authentication Example" width="200"/></td>
|
||||
<td align="center"><img src="public/screenshots/connectivity.png" alt="Connectivity Example" width="200"/></td>
|
||||
<td align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Authentication</b></td>
|
||||
<td align="center"><b>Connectivity</b></td>
|
||||
<td align="center"><b></b></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<img src="public/screenshots/collage.png" alt="Collage"/>
|
||||
|
||||
## Deployment and Usage Example
|
||||
|
||||
|
@ -112,7 +92,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||
|
||||
> [!TIP]
|
||||
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||
|
||||
2. **Domain Configuration**:
|
||||
|
||||
|
@ -123,10 +103,10 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||
- Install Newt or use another WireGuard client on private sites.
|
||||
- Automatically establish a connection from these sites to the central server.
|
||||
|
||||
4. **Configure Users & Roles**
|
||||
4. **Expose Resources**:
|
||||
|
||||
- Define organizations and invite users.
|
||||
- Implement user- or role-based permissions to control resource access.
|
||||
- Add resources to the central server and configure access control rules.
|
||||
- Access these resources securely from anywhere.
|
||||
|
||||
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
|
||||
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
|
||||
|
@ -134,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||
**Use Case Example - IoT Networks**:
|
||||
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
|
||||
|
||||
|
||||
<img src="public/screenshots/resources.png" alt="Resources"/>
|
||||
|
||||
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
|
||||
|
||||
## Similar Projects and Inspirations
|
||||
|
||||
**Cloudflare Tunnels**:
|
||||
|
|
Before Width: | Height: | Size: 577 KiB |
BIN
public/screenshots/collage.png
Normal file
After Width: | Height: | Size: 876 KiB |
Before Width: | Height: | Size: 447 KiB |
BIN
public/screenshots/resources.png
Normal file
After Width: | Height: | Size: 702 KiB |
Before Width: | Height: | Size: 484 KiB |
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 731 KiB |
Before Width: | Height: | Size: 415 KiB |
|
@ -67,6 +67,7 @@ import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
|||
import { Label } from "@app/components/ui/label";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
|
||||
const createResourceFormSchema = z
|
||||
.object({
|
||||
|
@ -222,6 +223,7 @@ export default function CreateResourceForm({
|
|||
|
||||
await fetchSites();
|
||||
await fetchDomains();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
setLoadingPage(false);
|
||||
};
|
||||
|
@ -241,7 +243,7 @@ export default function CreateResourceForm({
|
|||
protocol: data.protocol,
|
||||
proxyPort: data.http ? undefined : data.proxyPort,
|
||||
siteId: data.siteId,
|
||||
isBaseDomain: data.http ? undefined : data.isBaseDomain
|
||||
isBaseDomain: data.http ? data.isBaseDomain : undefined
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
|
@ -263,6 +265,7 @@ export default function CreateResourceForm({
|
|||
goToResource(id);
|
||||
} else {
|
||||
setShowSnippets(true);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -272,6 +275,21 @@ export default function CreateResourceForm({
|
|||
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
|
||||
}
|
||||
|
||||
const launchOptions = [
|
||||
{
|
||||
id: "http",
|
||||
title: "HTTPS Resource",
|
||||
description:
|
||||
"Proxy requests to your app over HTTPS using a subdomain or base domain."
|
||||
},
|
||||
{
|
||||
id: "raw",
|
||||
title: "Raw TCP/UDP Resource",
|
||||
description:
|
||||
"Proxy requests to your app over TCP/UDP using a port number."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
|
@ -293,7 +311,7 @@ export default function CreateResourceForm({
|
|||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{loadingPage ? (
|
||||
<LoaderPlaceholder height="500px" />
|
||||
<LoaderPlaceholder height="300px" />
|
||||
) : (
|
||||
<div>
|
||||
{!showSnippets && (
|
||||
|
@ -305,59 +323,6 @@ export default function CreateResourceForm({
|
|||
className="space-y-4"
|
||||
id="create-resource-form"
|
||||
>
|
||||
{!env.flags.allowRawResources || (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="http"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
HTTP
|
||||
Resource
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Toggle if
|
||||
this is an
|
||||
HTTP
|
||||
resource or
|
||||
a raw
|
||||
TCP/UDP
|
||||
resource.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!form.watch("http") && (
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Learn how to configure
|
||||
TCP/UDP resources
|
||||
</span>
|
||||
<SquareArrowOutUpRight
|
||||
size={14}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
@ -374,6 +339,121 @@ export default function CreateResourceForm({
|
|||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
Site
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: "Select site"}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
site
|
||||
found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${site.siteId}:${site.name}:${site.niceId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This site will
|
||||
provide connectivity
|
||||
to the resource.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.allowRawResources || (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
Resource Type
|
||||
</FormLabel>
|
||||
<StrategySelect
|
||||
options={launchOptions}
|
||||
defaultValue="http"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"http",
|
||||
value === "http"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormDescription>
|
||||
You cannot change the
|
||||
type of resource after
|
||||
creation.
|
||||
</FormDescription>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.watch("http") &&
|
||||
env.flags
|
||||
.allowBaseDomainResources && (
|
||||
|
@ -391,14 +471,19 @@ export default function CreateResourceForm({
|
|||
}
|
||||
onValueChange={(
|
||||
val
|
||||
) =>
|
||||
) => {
|
||||
setDomainType(
|
||||
val ===
|
||||
"basedomain"
|
||||
? "basedomain"
|
||||
: "subdomain"
|
||||
)
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"isBaseDomain",
|
||||
val ===
|
||||
"basedomain"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
@ -430,7 +515,7 @@ export default function CreateResourceForm({
|
|||
Subdomain
|
||||
</FormLabel>
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-1">
|
||||
<div className="w-1/2">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
|
@ -443,6 +528,7 @@ export default function CreateResourceForm({
|
|||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="border-r-0 rounded-r-none"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
@ -472,7 +558,7 @@ export default function CreateResourceForm({
|
|||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="rounded-l-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
@ -642,98 +728,6 @@ export default function CreateResourceForm({
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
Site
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: "Select site"}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
site
|
||||
found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${site.siteId}:${site.name}:${site.niceId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This site will
|
||||
provide connectivity
|
||||
to the resource.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
@ -775,8 +769,8 @@ export default function CreateResourceForm({
|
|||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Make sure to follow the full
|
||||
guide
|
||||
Learn how to configure TCP/UDP
|
||||
resources
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
|
|
|
@ -486,7 +486,7 @@ export default function ReverseProxyTargets(props: {
|
|||
onSubmit={addTargetForm.handleSubmit(addTarget)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start">
|
||||
{resource.http && (
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
|
@ -562,7 +562,7 @@ export default function ReverseProxyTargets(props: {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" variant="outlinePrimary">
|
||||
<Button type="submit" variant="outlinePrimary" className="mt-8">
|
||||
Add Target
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -322,14 +322,21 @@ export default function GeneralForm() {
|
|||
}
|
||||
onValueChange={(
|
||||
val
|
||||
) =>
|
||||
) => {
|
||||
setDomainType(
|
||||
val ===
|
||||
"basedomain"
|
||||
? "basedomain"
|
||||
: "subdomain"
|
||||
)
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"isBaseDomain",
|
||||
val ===
|
||||
"basedomain"
|
||||
? true
|
||||
: false
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
@ -359,7 +366,7 @@ export default function GeneralForm() {
|
|||
Subdomain
|
||||
</FormLabel>
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-1">
|
||||
<div className="w-1/2">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
|
@ -372,6 +379,7 @@ export default function GeneralForm() {
|
|||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="border-r-0 rounded-r-none"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
@ -401,7 +409,7 @@ export default function GeneralForm() {
|
|||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="rounded-l-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
|
|
@ -130,7 +130,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
<OrgProvider org={org}>
|
||||
<ResourceProvider resource={resource} authInfo={authInfo}>
|
||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||
<div className="mb-8">
|
||||
<div className="mb-4">
|
||||
<ResourceInfoBox />
|
||||
</div>
|
||||
{children}
|
||||
|
|
|
@ -149,6 +149,7 @@ export default function CreateSiteForm({
|
|||
setSiteDefaults(res.data.data);
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
setLoadingPage(false);
|
||||
};
|
||||
|
@ -270,7 +271,7 @@ PersistentKeepalive = 5`
|
|||
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||
|
||||
return loadingPage ? (
|
||||
<LoaderPlaceholder height="300px"/>
|
||||
<LoaderPlaceholder height="300px" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
|
@ -344,7 +345,6 @@ PersistentKeepalive = 5`
|
|||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
{" "}
|
||||
Learn how to install Newt on your system
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
|
@ -371,12 +371,16 @@ PersistentKeepalive = 5`
|
|||
onOpenChange={setIsOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="mx-auto">
|
||||
<div className="mx-auto mb-2">
|
||||
<CopyTextBox
|
||||
text={newtConfig}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
|
@ -418,10 +422,6 @@ PersistentKeepalive = 5`
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
@ -198,8 +198,7 @@ export default function VerifyEmailForm({
|
|||
<FormMessage />
|
||||
<FormDescription>
|
||||
We sent a verification code to your
|
||||
email address. Please enter the code
|
||||
to verify your email address.
|
||||
email address.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
|
@ -78,7 +78,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
|
|||
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
||||
|
||||
return (
|
||||
<CredenzaClose className={cn("mb-3 md:mb-0", className)} {...props}>
|
||||
<CredenzaClose className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)} {...props}>
|
||||
{children}
|
||||
</CredenzaClose>
|
||||
);
|
||||
|
@ -168,7 +168,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
|||
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
||||
|
||||
return (
|
||||
<CredenzaFooter className={className} {...props}>
|
||||
<CredenzaFooter className={cn("mt-8 md:mt-0", className)} {...props}>
|
||||
{children}
|
||||
</CredenzaFooter>
|
||||
);
|
||||
|
|
|
@ -19,7 +19,7 @@ export function SettingsSectionTitle({ children }: { children: React.ReactNode }
|
|||
}
|
||||
|
||||
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-muted-foreground">{children}</p>
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {
|
||||
|
|
53
src/components/StrategySelect.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
||||
|
||||
interface StrategyOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface StrategySelectProps {
|
||||
options: StrategyOption[];
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function StrategySelect({
|
||||
options,
|
||||
defaultValue,
|
||||
onChange
|
||||
}: StrategySelectProps) {
|
||||
return (
|
||||
<RadioGroup
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onChange}
|
||||
className="grid gap-4"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={option.id}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer rounded-lg border-2 p-4",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={option.id}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="pl-7">
|
||||
<div className="font-medium">{option.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
|
@ -20,8 +20,8 @@ const SelectTrigger = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
"rounded-md"
|
||||
"rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|