mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user