Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ desktop/frontend/
# .specs
*.specs
.docs
.pi

AGENTS.md
11 changes: 6 additions & 5 deletions internal/app/model_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const (

type Settings struct {
NotificationsEnabled bool
NotificationSound bool
NotificationSound bool
Theme string
}

Expand Down Expand Up @@ -113,12 +113,13 @@ var DefaultKeys = KeyMap{
key.WithHelp("s", "settings"),
),
Back: key.NewBinding(
key.WithKeys("esc", "b"),
key.WithHelp("esc/b", "back"),
key.WithKeys("esc"),
key.WithHelp("esc", "back"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q/ctrl+c", "quit"),
key.WithKeys("ctrl+c", "esc"),
key.WithHelp("ctrl+c", "quit"),
key.WithHelp("esc", "quit"),
),
Comment thread
sthbryan marked this conversation as resolved.
Help: key.NewBinding(
key.WithKeys("?"),
Expand Down
8 changes: 4 additions & 4 deletions internal/i18n/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ tip_dashboard: "💡 Tip: Web dashboard at"

# Settings UI
settings_title: "⚙ Settings"
settings_nav_hint: "↑/↓ navigate • space/enter toggle • esc back"
settings_nav_hint: "↑/↓ Navigate • space/enter Toggle • esc Back"

tunnel_logs: "📋 Tunnel Logs"
logs_nav_hint: "esc/b: back • ↑/↓: scroll"
logs_nav_hint: "esc: Back • ↑/↓: Scroll"

# UI elements
play_indicator: "▶"
Expand Down Expand Up @@ -243,8 +243,8 @@ validation_required_fields: "Name and Port are required"

# TUI Elements
app_name_tui: "🎲 Foundry Tunnel Manager"
web: "web"
config: "config"
web: "Web"
config: "Config"

# CLI
uninstall_not_found: "ftm is not installed or not in PATH"
Expand Down
10 changes: 5 additions & 5 deletions internal/i18n/locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ arrow_hint: "← → para cambiar"
numbers_hint: "solo números"
submit_new: "Crear Túnel"
submit_edit: "Guardar Cambios"
form_nav_hint: "TAB: navegar • ENTER: enviar • ESC: cancelar"
form_nav_hint: "TAB: Navegar • ENTER: Enviar • ESC: Cancelar"

# Empty states
no_tunnels: "No hay túneles configurados"
Expand All @@ -158,10 +158,10 @@ tip_dashboard: "💡 Tip: Panel web en"

# Settings UI
settings_title: "⚙ Configuración"
settings_nav_hint: "↑/↓ navegar • espacio/enter cambiar • esc volver"
settings_nav_hint: "↑/↓ Navegar • espacio/enter Cambiar • esc Volver"

tunnel_logs: "📋 Logs del Túnel"
logs_nav_hint: "esc/b: volver • ↑/↓: scroll"
logs_nav_hint: "esc: Volver • ↑/↓: Scroll"

# UI elements
play_indicator: "▶"
Expand Down Expand Up @@ -243,8 +243,8 @@ validation_required_fields: "Nombre y Puerto son requeridos"

# TUI Elements
app_name_tui: "🎲 Foundry Tunnel Manager"
web: "web"
config: "configuración"
web: "Web"
config: "Configuración"

# CLI
uninstall_not_found: "ftm no está instalado o no está en PATH"
Expand Down
149 changes: 66 additions & 83 deletions web-svelte/src/lib/components/Dropdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
left: "left-auto right-0",
right: "right-auto left-0",
"top-left": "bottom-full mb-1.5 left-auto right-0",
"top-right": "bottom-full mb-1.5 right-auto left-0"
"top-right": "bottom-full mb-1.5 right-auto left-0",
};

let t = $derived($translate);
Expand All @@ -38,81 +38,63 @@
}: DropdownProps = $props();

let isOpen = $state(false);
let isAnimating = $state(false);
let menuEl: HTMLDivElement | undefined = $state();

const isVisible = $derived(isOpen || isAnimating);
const menuPosition = $derived.by(() => {
const vert = align.startsWith("top") ? "" : "top-full mt-1.5";
return `${POSITION_MAP[align]} ${vert}`;
});

function open() {
if (isOpen) return;
if (isOpen || !menuEl) return;
isOpen = true;
isAnimating = true;
requestAnimationFrame(() => {
if (!menuEl) return;
animate(
menuEl,
{ opacity: 1, scale: 1, y: 0 },
{ type: "spring" },
).finished.then(() => {
isAnimating = false;
});
});
animate(menuEl, { opacity: 1, scale: 1, y: 0 }, { type: "spring" });
}

function close() {
if (!isOpen || !menuEl) {
isOpen = false;
isAnimating = false;
return;
}
if (!isOpen || !menuEl) return;
isOpen = false;
isAnimating = true;
animate(
menuEl,
{ opacity: 0, scale: 1, y: -4 },
{ type: "spring" },
).finished.then(() => {
isAnimating = false;
});
animate(menuEl, { opacity: 0, scale: 1, y: -4 }, { type: "spring" });
}

function toggle() {
function toggle(e: MouseEvent) {
e?.stopPropagation();
isOpen ? close() : open();
}

function handleOutsideClick(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".dropdown-container")) close();
if (!isOpen) return;
const target = e.target as HTMLElement;
if (target.closest(".dropdown-trigger") || target.closest(".dropdown-menu"))
return;
close();
}

function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}

$effect(() => {
if (!isOpen) return;
document.addEventListener("click", handleOutsideClick);
document.addEventListener("click", handleOutsideClick, true);
document.addEventListener("keydown", handleKeydown);
return () => {
document.removeEventListener("click", handleOutsideClick);
document.removeEventListener("click", handleOutsideClick, true);
document.removeEventListener("keydown", handleKeydown);
};
});

function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
</script>

<div class={cn("dropdown-container h-fit relative flex", className)}>
<button
type="button"
{id}
onclick={() => toggle()}
onclick={toggle}
aria-label={ariaLabel}
aria-expanded={isOpen}
aria-haspopup="true"
class={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs h-9 rounded-xl border min-h-9 cursor-pointer",
"dropdown-trigger flex items-center gap-1.5 px-3 py-2 text-xs h-9 rounded-xl border min-h-9 cursor-pointer",
"bg-card border-border text-text hover:bg-hover flex-1",
)}
>
Expand All @@ -127,49 +109,50 @@
{/if}
</button>

{#if isVisible}
<div
bind:this={menuEl}
id={id ? `${id}-menu` : undefined}
role="menu"
aria-orientation="vertical"
style="opacity: 0; scale: 0.95; transform: translateY(-4px);"
class={cn(
"absolute min-w-[150px] max-h-[300px] rounded-2xl border p-1 z-50 overflow-y-auto cursor-default",
menuPosition,
"bg-card border-border",
)}
>
{#each options as option}
{#if option.label === "separator"}
<div class="h-px my-1 mx-2 bg-border"></div>
{:else}
<button
type="button"
role="menuitem"
disabled={option.disabled}
onclick={() => {
close();
onSelect?.(option);
}}
class={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs rounded-xl text-left cursor-pointer",
"text-text bg-transparent border-none hover:bg-hover",
"disabled:opacity-50 disabled:cursor-not-allowed",
option.danger && "text-red-500 hover:bg-red-500/10",
)}
>
{#if option.icon}
{@const IconComponent =
option.icon as import("svelte").Component<{
size?: number;
}>}
<IconComponent size={16} />
{/if}
<span>{option.label}</span>
</button>
{/if}
{/each}
</div>
{/if}
<!-- Always in DOM, just hidden via opacity when closed -->
<div
bind:this={menuEl}
id={id ? `${id}-menu` : undefined}
role="menu"
inert={!isOpen}
aria-hidden={!isOpen}
aria-orientation="vertical"
style="opacity: 0; scale: 0.95; transform: translateY(-4px);"
class={cn(
"dropdown-menu absolute min-w-[150px] max-h-[300px] rounded-2xl border p-1 z-[9999] overflow-y-auto cursor-default",
menuPosition,
"bg-card border-border pointer-events-none",
isOpen && "pointer-events-auto",
)}
>
{#each options as option}
{#if option.label === "separator"}
<div class="h-px my-1 mx-2 bg-border"></div>
{:else}
<button
type="button"
role="menuitem"
disabled={option.disabled}
onclick={() => {
close();
onSelect?.(option);
}}
class={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs rounded-xl text-left cursor-pointer",
"text-text bg-transparent border-none hover:bg-hover",
"disabled:opacity-50 disabled:cursor-not-allowed",
option.danger && "text-red-500 hover:bg-red-500/10",
)}
>
{#if option.icon}
{@const IconComponent = option.icon as import("svelte").Component<{
size?: number;
}>}
<IconComponent size={16} />
{/if}
<span>{option.label}</span>
</button>
{/if}
{/each}
</div>
</div>
4 changes: 2 additions & 2 deletions web-svelte/src/lib/components/TunnelCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
}: TunnelCardProps = $props();

let t = $derived($translate);
const dropdownAlign = $derived(index === totalItems - 1 ? "top-left" : "left");
const dropdownAlign = $derived(index === totalItems - 1 && totalItems > 1 ? "top-left" : "left");

const toast = useToast();

Expand Down Expand Up @@ -200,7 +200,7 @@
</script>

<div
class="border rounded-3xl cursor-default transition-all duration-150 hover:scale-[1.01] bg-card border-border"
class="border rounded-3xl cursor-default transition-all duration-150 bg-card border-border"
>
<div class="flex flex-col">
<div
Expand Down
Loading