Просмотр исходного кода

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.
maziggy 3 месяцев назад
Родитель
Сommit
1b5683b3a3

+ 3 - 0
CHANGELOG.md

@@ -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 - 0
backend/app/core/database.py

@@ -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
backend/app/models/external_link.py

@@ -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 - 0
backend/app/schemas/external_link.py

@@ -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 - 0
frontend/src/api/client.ts

@@ -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

+ 22 - 0
frontend/src/components/AddExternalLinkModal.tsx

@@ -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
frontend/src/components/Layout.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -2707,6 +2707,7 @@ export default {
     noLinksConfigured: '外部リンクが設定されていません',
     deleteLink: 'リンクを削除',
     removeCustomIcon: 'カスタムアイコンを削除',
+    openInNewTab: '新しいタブで開く',
     placeholders: {
       linkName: 'マイリンク',
     },

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-COjr2ipw.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DLgJjh2G.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-ScV2ECQD.js


+ 2 - 2
static/index.html

@@ -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>

Некоторые файлы не были показаны из-за большого количества измененных файлов