Add "Open in new tab" toggle for external sidebar links (#338)

External links behind reverse proxies (Traefik, nginx) block iframe
embedding via X-Frame-Options/CSP headers. Add a per-link boolean
toggle so users can choose between iframe (default) and new-tab
behavior. Keyboard shortcuts also respect the setting.
This commit is contained in:
maziggy
2026-02-12 07:51:55 +01:00
parent 38a1d6f5e9
commit 1b5683b3a3
14 changed files with 162 additions and 92 deletions
+3
View File
@@ -11,6 +11,9 @@ All notable changes to Bambuddy will be documented in this file.
- **Printer Card Cover Image Not Updating Between Prints** — The cover image on the printer card only refreshed on page reload. The `<img>` URL was always the same (`/printers/{id}/cover`) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.
- **Telegram Bold Title Broken by Underscores in Message** ([#332](https://github.com/maziggy/bambuddy/issues/332)) — Telegram notifications showed literal `*Title*` asterisks instead of bold text when the message body contained underscores (e.g. job name `A1_plate_8`, error code `0300_0001`). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with `\_` so Markdown rendering stays enabled.
### New Features
- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
### Improved
- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
- **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
+6
View File
@@ -1118,6 +1118,12 @@ async def run_migrations(conn):
except OperationalError:
pass # Already applied
# Migration: Add open_in_new_tab column to external_links
try:
await conn.execute(text("ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0"))
except OperationalError:
pass # Already applied
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+2 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
@@ -16,6 +16,7 @@ class ExternalLink(Base):
url: Mapped[str] = mapped_column(String(500))
icon: Mapped[str] = mapped_column(String(50), default="link")
custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True) # Filename of uploaded icon
open_in_new_tab: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+3
View File
@@ -9,6 +9,7 @@ class ExternalLinkBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
url: str = Field(..., min_length=1, max_length=500, description="External URL")
icon: str = Field(default="link", max_length=50, description="Lucide icon name")
open_in_new_tab: bool = False
@field_validator("url")
@classmethod
@@ -31,6 +32,7 @@ class ExternalLinkUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=50)
url: str | None = Field(default=None, min_length=1, max_length=500)
icon: str | None = Field(default=None, max_length=50)
open_in_new_tab: bool | None = None
@field_validator("url")
@classmethod
@@ -45,6 +47,7 @@ class ExternalLinkResponse(ExternalLinkBase):
"""Response schema for external links."""
id: int
open_in_new_tab: bool
custom_icon: str | None = None
sort_order: int
created_at: datetime
+3
View File
@@ -1804,6 +1804,7 @@ export interface ExternalLink {
name: string;
url: string;
icon: string;
open_in_new_tab: boolean;
custom_icon: string | null;
sort_order: number;
created_at: string;
@@ -1814,12 +1815,14 @@ export interface ExternalLinkCreate {
name: string;
url: string;
icon: string;
open_in_new_tab?: boolean;
}
export interface ExternalLinkUpdate {
name?: string;
url?: string;
icon?: string;
open_in_new_tab?: boolean;
}
// Permission type - all available permissions
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
import { Button } from './Button';
@@ -11,6 +12,7 @@ interface AddExternalLinkModalProps {
}
export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isEditing = !!link;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -18,6 +20,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
const [name, setName] = useState(link?.name || '');
const [url, setUrl] = useState(link?.url || '');
const [icon, setIcon] = useState(link?.icon || 'link');
const [openInNewTab, setOpenInNewTab] = useState(link?.open_in_new_tab || false);
const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
const [customIconPreview, setCustomIconPreview] = useState<string | null>(
link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
@@ -137,6 +140,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
name: name.trim(),
url: url.trim(),
icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
open_in_new_tab: openInNewTab,
};
if (isEditing) {
@@ -213,6 +217,24 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
/>
</div>
{/* Open in New Tab */}
<div className="flex items-center justify-between">
<label className="text-sm text-bambu-gray">{t('externalLinks.openInNewTab')}</label>
<button
type="button"
onClick={() => setOpenInNewTab(!openInNewTab)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
openInNewTab ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
openInNewTab ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Icon Section */}
<div className="space-y-3">
<label className="block text-sm text-bambu-gray">Icon</label>
+57 -28
View File
@@ -347,9 +347,14 @@ export function Layout() {
e.preventDefault();
if (isExternalLinkId(id)) {
// External link - navigate to iframe page
const linkId = id.replace('ext-', '');
navigate(`/external/${linkId}`);
// External link
const extLink = extLinksMap.get(id);
if (extLink?.open_in_new_tab) {
window.open(extLink.url, '_blank', 'noopener,noreferrer');
} else {
const linkId = id.replace('ext-', '');
navigate(`/external/${linkId}`);
}
} else {
// Internal nav item
const navItem = navItemsMap.get(id);
@@ -451,31 +456,55 @@ export function Layout() {
: ''
}`}
>
<NavLink
to={`/external/${link.id}`}
className={({ isActive }) =>
`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</NavLink>
{link.open_in_new_tab ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</a>
) : (
<NavLink
to={`/external/${link.id}`}
className={({ isActive }) =>
`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</NavLink>
)}
</li>
);
} else {
+1
View File
@@ -2782,6 +2782,7 @@ export default {
noLinksConfigured: 'Keine externen Links konfiguriert',
deleteLink: 'Link löschen',
removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',
openInNewTab: 'In neuem Tab öffnen',
placeholders: {
linkName: 'Mein Link',
},
+1
View File
@@ -2783,6 +2783,7 @@ export default {
noLinksConfigured: 'No external links configured',
deleteLink: 'Delete Link',
removeCustomIcon: 'Remove custom icon',
openInNewTab: 'Open in new tab',
placeholders: {
linkName: 'My Link',
},
+1
View File
@@ -2707,6 +2707,7 @@ export default {
noLinksConfigured: '外部リンクが設定されていません',
deleteLink: 'リンクを削除',
removeCustomIcon: 'カスタムアイコンを削除',
openInNewTab: '新しいタブで開く',
placeholders: {
linkName: 'マイリンク',
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CJOs5WMU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLgJjh2G.css">
<script type="module" crossorigin src="/assets/index-ScV2ECQD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-COjr2ipw.css">
</head>
<body>
<div id="root"></div>