From ba3dc613d130a3c153c3b23e1c678f4201218c9b Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 10 May 2026 11:15:47 +0200 Subject: [PATCH] Changed app defaults --- backend/app/schemas/settings.py | 4 +- frontend/src/__tests__/mocks/handlers.ts | 131 ++++++++++++++++++ .../pages/InventoryPageCopyButton.test.tsx | 4 + .../pages/InventoryPageDeepLink.test.tsx | 18 ++- frontend/src/components/Layout.tsx | 9 +- frontend/src/contexts/ThemeContext.tsx | 4 +- .../{index-CBFhMIk6.js => index-CPo_tbez.js} | 4 +- static/index.html | 2 +- 8 files changed, 160 insertions(+), 16 deletions(-) rename static/assets/{index-CBFhMIk6.js => index-CPo_tbez.js} (94%) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 71d3f0974..d48f008ad 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -122,9 +122,9 @@ class AppSettings(BaseModel): ) # Dark mode theme settings - dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant") + dark_style: str = Field(default="vibrant", description="Dark mode style: classic, glow, vibrant") dark_background: str = Field( - default="neutral", description="Dark mode background: neutral, warm, cool, oled, slate, forest" + default="cool", description="Dark mode background: neutral, warm, cool, oled, slate, forest" ) dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red") diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 06eab5467..fb0259e61 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -421,4 +421,135 @@ export const handlers = [ total_folders: 0, }); }), + + // ======================================================================== + // Read-on-mount fallbacks + // ------------------------------------------------------------------------ + // Components fire background fetches when they mount (status badges, + // notification-template loaders, oidc/ldap/2fa probes, etc.). Without + // handlers these fall through to the real network and the rejected promise + // surfaces as an ECONNREFUSED stack trace in test stderr. These minimal + // disabled-state stubs silence the trace. Per-test handlers added via + // server.use(...) still win. + // ======================================================================== + + // Lists → empty arrays + http.get('/api/v1/archives/', () => HttpResponse.json([])), + http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([])), + http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])), + http.get('/api/v1/auth/tokens', () => HttpResponse.json([])), + http.get('/api/v1/auth/tokens/all', () => HttpResponse.json([])), + http.get('/api/v1/external-links/', () => HttpResponse.json([])), + http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), + http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), + http.get('/api/v1/inventory/colors', () => HttpResponse.json([])), + http.get('/api/v1/inventory/spools', () => HttpResponse.json([])), + http.get('/api/v1/library/folders', () => HttpResponse.json([])), + http.get('/api/v1/library/folders/by-archive/:id', () => HttpResponse.json([])), + http.get('/api/v1/maintenance/overview', () => HttpResponse.json([])), + http.get('/api/v1/makerworld/recent-imports', () => HttpResponse.json([])), + http.get('/api/v1/notification-templates', () => HttpResponse.json([])), + http.get('/api/v1/pending-uploads/', () => HttpResponse.json([])), + http.get('/api/v1/printers/:id/ams-labels', () => HttpResponse.json([])), + http.get('/api/v1/printers/:id/slot-presets', () => HttpResponse.json([])), + http.get('/api/v1/smart-plugs/by-printer/:id', () => HttpResponse.json([])), + http.get('/api/v1/smart-plugs/by-printer/:id/scripts', () => HttpResponse.json([])), + http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([])), + http.get('/api/v1/spoolman/inventory/filaments', () => HttpResponse.json([])), + http.get('/api/v1/spoolman/spools/linked', () => HttpResponse.json([])), + http.get('/api/v1/spoolman/spools/unlinked', () => HttpResponse.json([])), + http.get('/api/v1/users/', () => HttpResponse.json([])), + + // Status / object endpoints → minimal disabled-state responses + http.get('/api/v1/archives/purge/settings', () => + HttpResponse.json({ enabled: false, retention_days: 0 }) + ), + http.get('/api/v1/auth/2fa/status', () => + HttpResponse.json({ totp_enabled: false, email_otp_enabled: false, backup_codes_remaining: 0 }) + ), + http.get('/api/v1/auth/advanced-auth/status', () => + HttpResponse.json({ advanced_auth_enabled: false, smtp_configured: false }) + ), + http.get('/api/v1/auth/ldap/status', () => + HttpResponse.json({ ldap_enabled: false, ldap_configured: false }) + ), + http.get('/api/v1/cloud/status', () => + HttpResponse.json({ is_authenticated: false, email: null, region: null }) + ), + http.get('/api/v1/firmware/updates/:id', () => HttpResponse.json(null)), + http.get('/api/v1/github-backup/status', () => + HttpResponse.json({ + enabled: false, + configured: false, + last_backup_at: null, + last_error: null, + schedule_enabled: false, + }) + ), + http.get('/api/v1/library/trash', () => HttpResponse.json({ items: [], total: 0 })), + http.get('/api/v1/library/trash/settings', () => + HttpResponse.json({ enabled: false, retention_days: 0 }) + ), + http.get('/api/v1/obico/status', () => + HttpResponse.json({ + is_running: false, + last_error: null, + per_printer: {}, + thresholds: { low: 0, high: 0 }, + history: [], + enabled: false, + ml_url: '', + sensitivity: 'medium', + action: 'notify', + poll_interval: 30, + external_url_configured: false, + }) + ), + http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)), + http.get('/api/v1/settings/check-ffmpeg', () => + HttpResponse.json({ available: false, version: null }) + ), + http.get('/api/v1/settings/mqtt/status', () => + HttpResponse.json({ enabled: false, connected: false, broker: '', port: 1883, topic_prefix: '' }) + ), + // NOTE: /api/v1/settings/spoolman intentionally NOT stubbed globally — + // src/__tests__/api/client.test.ts uses it as an auth-header canary with + // its own setupServer instance, and a global default would race with that + // file's runtime handlers. Tests that need this endpoint stub it locally. + http.get('/api/v1/settings/virtual-printer', () => HttpResponse.json({})), + http.get('/api/v1/spoolman/status', () => + HttpResponse.json({ enabled: false, connected: false, url: null }) + ), + http.get('/api/v1/system/storage-usage', () => + HttpResponse.json({ + roots: [], + total_bytes: 0, + total_formatted: '0 B', + categories: [], + other_breakdown: [], + scan_errors: 0, + generated_at: '2024-01-01T00:00:00Z', + cache: { hit: false, age_seconds: 0, max_age_seconds: 0 }, + }) + ), + http.get('/api/v1/updates/check', () => + HttpResponse.json({ update_available: false, latest_version: null }) + ), + http.get('/api/v1/updates/status', () => + HttpResponse.json({ update_available: false, latest_version: null }) + ), + http.get('/api/v1/updates/version', () => + HttpResponse.json({ version: '0.1.5', commit: 'test' }) + ), + http.get('/openapi.json', () => + HttpResponse.json({ openapi: '3.0.0', info: { title: 'Bambuddy', version: '0.1.5' }, paths: {} }) + ), + http.post('/api/v1/cloud/filament-info', () => HttpResponse.json({ filaments: [] })), + http.post('/api/v1/printers/camera/stream-token', () => + HttpResponse.json({ token: 'test-token', expires_at: '2099-01-01T00:00:00Z' }) + ), + http.put('/api/v1/settings/', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json(body); + }), ]; diff --git a/frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx b/frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx index 8132fc145..6b25c999f 100644 --- a/frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx +++ b/frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx @@ -149,8 +149,12 @@ function setupHandlers(spools: unknown[] = [MOCK_SPOOL]) { http.get('/api/v1/cloud/local-presets', () => HttpResponse.json({ filament: [], printer: [], process: [] }) ), + http.get('/api/v1/local-presets/', () => + HttpResponse.json({ filament: [], printer: [], process: [] }) + ), http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])), http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])), + http.get('/api/v1/inventory/colors', () => HttpResponse.json([])), http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])), http.get('/api/v1/printers/', () => HttpResponse.json([])), ); diff --git a/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx b/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx index cda91b217..8aab103d4 100644 --- a/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx +++ b/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx @@ -120,6 +120,7 @@ function setupCommonHandlers(spoolList: object[]) { HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' }) ), http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolList)), + http.get('/api/v1/inventory/spools/:id/usage', () => HttpResponse.json([])), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), // Deep-link flows open SpoolFormModal, which fires off these fetches the @@ -133,8 +134,12 @@ function setupCommonHandlers(spoolList: object[]) { http.get('/api/v1/cloud/local-presets', () => HttpResponse.json({ filament: [], printer: [], process: [] }) ), + http.get('/api/v1/local-presets/', () => + HttpResponse.json({ filament: [], printer: [], process: [] }) + ), http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])), http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])), + http.get('/api/v1/inventory/colors', () => HttpResponse.json([])), http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])), http.get('/api/v1/printers/', () => HttpResponse.json([])), ); @@ -249,7 +254,11 @@ describe('InventoryPage - deep-link ?spool= flow', () => { describe('scenario 5 (T-Gap 8): deep-link works in Spoolman mode', () => { beforeEach(() => { window.history.pushState({}, '', '/?spool=42'); - // Override settings with Spoolman enabled + // Inherit common modal stubs (cloud/status, colors, presets, etc.) — then + // override the bits that flip into Spoolman mode. Runtime handlers added + // last win, so the spoolman_enabled: true settings response shadows the + // common one. + setupCommonHandlers([BASE_SPOOL]); server.use( http.get('/api/v1/settings/', () => HttpResponse.json({ ...MOCK_SETTINGS, spoolman_enabled: true, spoolman_url: 'http://spoolman.local:7912' }) @@ -257,10 +266,11 @@ describe('InventoryPage - deep-link ?spool= flow', () => { http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://spoolman.local:7912', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' }) ), - // In Spoolman mode the component fetches from /api/v1/spoolman/inventory/spools + // Spoolman-mode list + per-spool fetches the modal makes when editing a Spoolman spool http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([BASE_SPOOL])), - http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), - http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), + http.get('/api/v1/spoolman/inventory/spools/:id', () => HttpResponse.json(BASE_SPOOL)), + http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])), + http.get('/api/v1/spoolman/inventory/filaments', () => HttpResponse.json([])), ); }); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8db7d7585..3265cb3fa 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -26,17 +26,16 @@ interface NavItem { } export const defaultNavItems: NavItem[] = [ - // Primary workflow items { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' }, + { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' }, { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' }, { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' }, - { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' }, - { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, - { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, - { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' }, { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' }, + { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, + { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, + { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' }, // User-account features: kept adjacent to Settings intentionally { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index a87236961..17b7dc6cb 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -40,10 +40,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // Dark mode settings const [darkStyle, setDarkStyleState] = useState(() => { - return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic'; + return (localStorage.getItem('dark-style') as ThemeStyle) || 'vibrant'; }); const [darkBackground, setDarkBackgroundState] = useState(() => { - return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral'; + return (localStorage.getItem('dark-background') as DarkBackground) || 'cool'; }); const [darkAccent, setDarkAccentState] = useState(() => { return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green'; diff --git a/static/assets/index-CBFhMIk6.js b/static/assets/index-CPo_tbez.js similarity index 94% rename from static/assets/index-CBFhMIk6.js rename to static/assets/index-CPo_tbez.js index 9913c8bb8..8224d4523 100644 --- a/static/assets/index-CBFhMIk6.js +++ b/static/assets/index-CPo_tbez.js @@ -32,7 +32,7 @@ ON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1) 乘數:Wh→kWh使用0.001,MWh→kWh使用1000。`,mqttStateHint:`JSON路徑從JSON負載中提取值。原始值請留空。 ON值:表示"ON"的確切字串。留空以自動檢測(ON、true、1)。`,restControl:"控制",restOnUrl:"開啟 URL",restOffUrl:"關閉 URL",restOnBody:"開啟請求內容",restOffBody:"關閉請求內容",restMethod:"HTTP 方法",restHeaders:"自訂標頭(JSON)",restStatusUrl:"狀態 URL",restStatusPath:"狀態 JSON 路徑",restStatusOnValue:"ON 值",restPowerUrl:"功率URL",restPowerPath:"功率 JSON 路徑",restPowerMultiplier:"功率乘數",restEnergyUrl:"能耗URL",restEnergyPath:"能耗 JSON 路徑",restEnergyMultiplier:"能耗乘數",restUrlRequired:"REST 插座至少需要一個 URL(ON 或 OFF)",restHeadersHint:'例如:{"Authorization": "Bearer your-token"}',restBodyHint:'例如:ON、{"state": "on"}',restStatusHint:"用於輪詢目前狀態的 URL",restPathHint:"例如:state 或 data.power.status",restPowerUrlHint:"功率資料的獨立URL(留空則使用狀態URL)",restEnergyUrlHint:"能耗資料的獨立URL(留空則使用狀態URL)",restEnergyHint:"每個值可以使用獨立的URL,或回退到狀態 URL。使用乘數進行單位轉換(例如:0.001 將 Wh 轉換為 kWh)。",testConnection:"測試連線",connectionSuccess:"連線成功",noSwitchesInSwitchbar:"開關欄中沒有開關",enableSwitchbarHint:'在設定 > 智慧插座中啟用"在開關欄顯示"'},notifications:{providerTypes:{callmebot:"CallMeBot/WhatsApp",ntfy:"ntfy",pushover:"Pushover",telegram:"Telegram",email:"電子郵件",discord:"Discord",webhook:"Webhook",homeassistant:"Home Assistant"},providerDescriptions:{email:"SMTP 電子郵件通知",telegram:"透過 Telegram 機器人傳送通知",discord:"透過 Webhook 傳送到 Discord 頻道",ntfy:"免費、可自託管的推送通知",pushover:"簡單、可靠的推送通知",callmebot:"透過 CallMeBot 免費傳送 WhatsApp 通知",webhook:"通用 HTTP POST 到任意 URL",homeassistant:"Home Assistant 儀表板中的持久通知"},lastSuccess:"上次:{{date}}",error:"錯誤",printer:"印表機:",allPrinters:"所有印表機",sendTestNotification:"傳送測試通知",eventSettings:"事件設定",enabled:"已啟用",sendFromProvider:"從此提供者傳送通知",printEvents:"列印事件",printerStatus:"印表機狀態",amsAlarms:"AMS 警報",amsHtAlarms:"AMS-HT 警報",printQueue:"列印佇列",start:"開始",plateCheck:"熱床檢測",complete:"完成",failed:"失敗",stopped:"已停止",progress:"進度",offline:"離線",lowFilament:"耗材不足",maintenance:"維護",amsHumidity:"AMS 濕度",amsTemp:"AMS 溫度",amsHtHumidity:"AMS-HT 濕度",amsHtTemp:"AMS-HT 溫度",bedCooled:"熱床已冷卻",firstLayer:"首層完成",quiet:"免打擾",digest:"摘要 {{time}}",printStarted:"列印已開始",plateNotEmpty:"熱床非空",plateNotEmptyDescription:"列印前偵測到物體",printCompleted:"列印已完成",bedCooledLabel:"熱床已冷卻",bedCooledDescription:"列印後熱床溫度降至閾值以下",firstLayerCompleteLabel:"首層列印完成",firstLayerCompleteDescription:"首層完成時傳送帶照片的通知",missingSpoolAssignmentLabel:"缺少料卷分配",missingSpoolAssignmentDescription:"當列印開始且所需料盤沒有分配料卷時傳送通知",printFailed:"列印失敗",printStopped:"列印已停止",progressMilestones:"進度里程碑",progressMilestonesDescription:"在 25%、50%、75% 時通知",printerOffline:"印表機離線",printerError:"印表機錯誤",lowFilamentLabel:"耗材不足",maintenanceDue:"需要維護",maintenanceDueDescription:"需要維護時通知",amsHumidityHigh:"AMS 濕度過高",amsHumidityHighDescription:"普通 AMS 濕度超過閾值",amsTemperatureHigh:"AMS 溫度過高",amsTemperatureHighDescription:"普通 AMS 溫度超過閾值",amsHtHumidityHigh:"AMS-HT 濕度過高",amsHtHumidityHighDescription:"AMS-HT 濕度超過閾值",amsHtTemperatureHigh:"AMS-HT 溫度過高",amsHtTemperatureHighDescription:"AMS-HT 溫度超過閾值",inventoryAlerts:"庫存警報",stockReorderAlert:"補貨警報",stockReorderAlertDescription:"SKU 已達到補貨點",stockBreakAlert:"斷貨警報",stockBreakAlertDescription:"庫存將在補貨到達前耗盡",jobAdded:"任務已新增",jobAddedDescription:"任務已新增到佇列",jobAssigned:"任務已分配",jobAssignedDescription:"基於模型的任務已分配給印表機",jobStarted:"任務已開始",jobStartedDescription:"佇列任務已開始列印",jobWaiting:"任務等待中",jobWaitingDescription:"任務正在等待耗材或印表機",jobSkipped:"任務已跳過",jobSkippedDescription:"任務已跳過(上一個失敗)",jobFailed:"任務失敗",jobFailedDescription:"任務啟動失敗",queueComplete:"佇列已完成",queueCompleteDescription:"所有佇列任務已完成",quietHours:"免打擾時段",noNotificationsDuring:"在此時段內不傳送通知",editProviderToChangeQuietHours:"編輯提供者以更改免打擾時段",dailyDigest:"每日摘要",batchNotifications:"將通知彙總為每日摘要",sendAt:"傳送於 {{time}}",editProviderToChangeDigestTime:"編輯提供者以更改摘要時間",edit:"編輯",deleteProvider:"刪除通知提供者",deleteConfirm:'確定要刪除"{{name}}"嗎?此操作無法復原。',delete:"刪除",addTitle:"新增通知提供者",editTitle:"編輯通知提供者",nameLabel:"名稱 *",namePlaceholder:"我的通知",providerTypeLabel:"提供者類型 *",configuration:"設定",testConfiguration:"測試設定",printerFilter:"印表機篩選",onlyFromPrinter:"僅傳送來自此印表機的事件通知",quietHoursDnd:"免打擾時段",quietStart:"開始",quietEnd:"結束",dailyDigestLabel:"每日摘要",sendDigestAt:"傳送摘要於",digestCollected:"事件將被收集並在此時間作為單條摘要傳送",notificationEvents:"通知事件",progressPercent:"(25%、50%、75%)",bedCooledAfterPrint:"(列印完成後)",eventPriority:{sectionTitle:"ntfy 優先級",helpNtfy:"為每個已啟用的事件選擇優先級。ntfy 使用它來升級提醒(聲音、可見性、推播行為)。此處未設定的級別將使用 ntfy 伺服器預設值。",min:"最低",low:"低",default:"預設",high:"高",urgent:"緊急"},cancel:"取消",save:"儲存",add:"新增",nameRequired:"名稱為必填項",fieldRequired:"{{field}}為必填項",phoneNumber:"電話號碼",apiKey:"API 金鑰",serverUrl:"伺服器 URL",topic:"主題",authToken:"認證權杖",userKey:"使用者金鑰",appToken:"應用程式權杖",priority:"優先順序",botToken:"機器人權杖",chatId:"聊天 ID",smtpServer:"SMTP 伺服器",smtpPort:"SMTP 連接埠",security:"安全",authentication:"認證",username:"使用者名稱",password:"密碼",fromEmail:"寄件人信箱",toEmail:"收件人信箱",webhookUrl:"Webhook URL",payloadFormat:"負載格式",authorization:"授權",titleFieldName:"標題欄位名",messageFieldName:"訊息欄位名",editTemplate:"編輯範本:{{name}}",titleLabel:"標題",bodyLabel:"正文",titlePlaceholder:"通知標題...",bodyPlaceholder:"通知正文...",availableVariables:"可用變數",clickToInsert:"點選插入到正文游標位置",livePreview:"即時預覽",hide:"隱藏",show:"顯示",loadingPreview:"載入預覽中...",enterTemplateContent:"輸入範本內容以檢視預覽",titlePreview:"標題:",bodyPreview:"正文:",resetToDefault:"恢復預設",titleRequired:"標題為必填項",bodyRequired:"正文為必填項",notificationLog:"通知日誌",showFailedOnly:"僅顯示失敗",last24Hours:"最近 24 小時",last7Days:"最近 7 天",last30Days:"最近 30 天",last90Days:"最近 90 天",justNow:"剛剛",noFailedNotifications:"沒有失敗的通知",noNotificationsLogged:"沒有通知紀錄",unknownProvider:"未知提供者",logTitle:"標題",logMessage:"訊息",logError:"錯誤",logProvider:"提供者:{{type}}",logTime:"時間:{{time}}",refresh:"重新整理",clearOld:"清除舊紀錄",statsSummary:"最近 {{days}} 天:",statsNotifications:"條通知",statsSent:"{{count}} 條已傳送",statsFailed:"{{count}} 條失敗",eventTypes:{print_start:"列印已開始",print_complete:"列印完成",print_failed:"列印失敗",print_stopped:"列印已停止",print_progress:"進度",printer_offline:"印表機離線",printer_error:"印表機錯誤",filament_low:"耗材不足",maintenance_due:"需要維護",test:"測試"},userEmail:{title:"通知",emailNotifications:"郵件通知",emailNotificationsDesc:"接收您自己列印任務的郵件通知。郵件將透過進階身份驗證中設定的 SMTP 設定傳送。",sendingTo:"通知將傳送至",noEmailWarning:"您的帳戶沒有郵件地址。請聯絡管理員新增。",printJobNotifications:"列印任務通知",printJobNotificationsDesc:"選擇哪些事件會觸發您提交的列印任務的郵件通知。",printJobStarts:"列印任務開始",printJobStartsDesc:"當您的列印任務開始時收到通知。",printJobFinishes:"列印任務完成",printJobFinishesDesc:"當您的列印任務成功完成時收到通知。",printErrors:"列印錯誤",printErrorsDesc:"當您的列印任務失敗或遇到錯誤時收到通知。",printJobStops:"列印任務停止",printJobStopsDesc:"當您的列印任務被取消或停止時收到通知。",saveSuccess:"通知偏好設定已儲存。",saveError:"儲存通知偏好設定失敗。"}},richTextEditor:{bold:"粗體",italic:"斜體",underline:"底線",bulletList:"無序列表",numberedList:"有序列表",alignLeft:"左對齊",alignCenter:"居中對齊",alignRight:"右對齊",addLink:"新增連結",removeLink:"移除連結"},externalLinks:{noLinksConfigured:"未設定外部連結",deleteLink:"刪除連結",removeCustomIcon:"移除自訂圖示",openInNewTab:"在新標籤頁中開啟",placeholders:{linkName:"我的連結"}},keyboardShortcuts:{title:"鍵盤快捷鍵",navigation:"導航",archivesSection:"歸檔",kProfilesSection:"K 值設定",generalSection:"通用",shortcuts:{goToPrinters:"前往印表機",goToArchives:"前往歸檔",goToQueue:"前往佇列",goToStats:"前往統計",goToProfiles:"前往雲端設定",goToSettings:"前往設定",focusSearch:"聚焦搜尋",openUploadModal:"開啟上傳對話方塊",clearSelection:"清除選擇 / 取消焦點",contextMenu:"卡片右鍵選單",refreshProfiles:"重新整理設定",newProfile:"新增設定",exitSelectionMode:"結束選擇模式",showHelp:"顯示此協助"},footer:"按 Esc 或點選外部關閉"},notificationLog:{title:"通知日誌",events:{printStarted:"列印開始",printComplete:"列印完成",printFailed:"列印失敗",printStopped:"列印停止",progress:"進度",printerOffline:"印表機離線",printerError:"印表機錯誤",lowFilament:"耗材不足",maintenanceDue:"維護到期",test:"測試"},timeAgo:{justNow:"剛剛",minutesAgo:"{{minutes}} 分鐘前",hoursAgo:"{{hours}} 小時前"}},restoreBackup:{title:"恢復備份",restoring:"恢復中...",restoreComplete:"恢復完成",restoreFailed:"恢復失敗",importSettings:"從備份檔案匯入設定",pleaseWait:"請稍候,正在恢復您的資料",clickToSelect:"點選選擇備份檔案(.json 或 .zip)",howDuplicateHandling:"重複處理方式:",categories:{printers:"印表機",smartPlugs:"智慧插座",notificationProviders:"通知提供者",filaments:"耗材",archives:"歸檔",pendingUploads:"待處理上傳",settingsTemplates:"設定和範本"},matchingInfo:{printers:"按序列號匹配",smartPlugs:"按 IP 位址匹配",notificationProviders:"按名稱匹配",filaments:"按名稱 + 類型 + 品牌匹配",archives:"按內容雜湊匹配",pendingUploads:"按檔名匹配",settingsTemplates:"始終覆蓋"},replaceExisting:"替換現有資料",keepExisting:"保留現有資料",replaceDescription:"用備份資料覆蓋已存在的項目",keepDescription:"僅恢復不存在的項目",caution:"注意:",cautionText:"覆蓋將用備份資料替換您目前的設定。出於安全考慮,印表機存取碼永遠不會被覆蓋。",itemsRestored:"已恢復項目",itemsSkipped:"已跳過項目",restored:"已恢復",skipped:"已跳過(已存在)",filesLabel:"檔案(3MF、縮圖等)",newApiKeysGenerated:"已產生新 API 金鑰",newApiKeysWarning:"這些金鑰僅顯示一次。請立即複製!",processingBackup:"處理備份檔案中...",noDataFound:"備份檔案中未找到可恢復的資料。",failedToRestore:"恢復備份失敗。請檢查檔案格式。"},backupExport:{title:"匯出備份",selectData:"選擇要包含的資料",selectAll:"全選",selectNone:"全不選",categoryDescriptions:{settings:"語言、主題、更新偏好",notifications:"ntfy、Pushover、Discord 等",templates:"自訂訊息範本",smartPlugs:"Tasmota 插座設定",externalLinks:"側邊欄外部服務連結",printers:"印表機資訊(不含存取碼)",plateDetection:"空列印板參考影像",filaments:"耗材類型和成本",maintenance:"自訂維護計畫",archives:"所有列印資料 + 檔案(3MF、縮圖、照片)",projects:"專案、材料清單和附件",pendingUploads:"虛擬印表機待審核的上傳",apiKeys:"Webhook API 金鑰(匯入時產生新金鑰)"},requiresPrinters:"需要選擇印表機",zipFileWarning:"將建立 ZIP 檔案。",zipFileDescription:"包括所有 3MF 檔案、縮圖、縮時攝影和照片。這可能需要一些時間並產生較大的檔案。",includeAccessCodes:"包含存取碼",includeAccessCodesDescription:"用於轉移到另一臺機器",includeAccessCodesWarning:"存取碼將以明文形式包含。請妥善保管此備份檔案!",categoriesSelected:"已選擇 {{selectedCount}} 個類別"},pendingUploads:{placeholders:{notes:"新增關於此列印的備註..."},discardUpload:"丟棄上傳",archiveAllUploads:"歸檔所有上傳",discardAllUploads:"丟棄所有上傳",archive:"歸檔",timeAgo:{justNow:"剛剛",minutesAgo:"{{minutes}} 分鐘前",hoursAgo:"{{hours}} 小時前",daysAgo:"{{days}} 天前"}},apiBrowser:{placeholders:{requestBody:"JSON 請求體...",searchEndpoints:"搜尋端點..."}},configureAmsSlot:{title:"設定 AMS 槽位",slotConfigured:"槽位已設定!",configuringSlot:"正在設定槽位:",slotLabel:"{{ams}} 槽位 {{slot}}",searchPresets:"搜尋預設...",colorPlaceholder:"顏色名稱或十六進位(例如:棕色、FF8800)",clearCustomColor:"清除自訂顏色",noCloudPresets:"無雲端預設。登入拓竹雲以同步。",noPresetsAvailable:"無可用預設。登入拓竹雲或匯入本機設定。",noMatchingPresets:"未找到匹配的預設。",custom:"自訂",builtin:"內建",settingsSentToPrinter:"設定已傳送到印表機",filamentProfile:"耗材設定",kProfileLabel:"K 值設定(壓力推進)",filteringFor:"篩選:{{material}}",noKProfile:"無 K 值設定(使用預設值 0.020)",noMatchingKProfiles:"未找到匹配的 K 值設定。將使用預設 K=0.020。",selectFilamentFirst:"請先選擇耗材設定",kFromCalibration:"K={{value}}(來自印表機校準)",customColorLabel:"自訂顏色(可選)",presetColors:"{{name}} 顏色:",showLessColors:"顯示更少顏色",showMoreColors:"顯示更多顏色",clear:"清除",hexLabel:"十六進位:#{{hex}}",resetting:"重設中...",resetSlot:"重設槽位",cancel:"取消",configuring:"設定中...",configureSlot:"設定槽位"},githubBackup:{title:"Git 備份",history:"歷史",downloadBackup:"下載備份",restoreBackup:"恢復備份",noBackupsYet:"尚無備份"},emailSettings:{placeholders:{fromName:"BamBuddy"}},tagManagement:{searchTags:"搜尋標籤...",renameTag:"重新命名標籤",deleteTag:"刪除標籤"},notificationTemplates:{placeholders:{title:"通知標題...",body:"通知正文..."}},batchTag:{placeholders:{newTag:"輸入新標籤..."}},photoGallery:{deletePhoto:"刪除照片"},filamentHoverCard:{copySpoolUuid:"複製耗材 UUID"},kProfilesView:{hasNote:"有備註",copyProfile:"複製設定"},layout:{openMenu:"開啟選單",noPermissionSystemInfo:"您沒有檢視系統資訊的權限"},dashboard:{dragToReorder:"拖曳以重新排列",hideWidget:"隱藏小工具"},notificationProviderCard:{deleteNotificationProvider:"刪除通知提供者"},fileManagerModal:{closeFileManager:"關閉檔案管理器",sortFiles:"排序檔案",goToParentFolder:"返回上級資料夾",threeView:"3D 檢視"},embeddedCameraViewer:{refreshStream:"重新整理流",close:"關閉",zoomOut:"縮小",resetZoom:"重設縮放",zoomIn:"放大",dragToResize:"拖曳調整大小"},timelapseViewer:{skipBack5s:"後退 5 秒",skipForward5s:"前進 5 秒"},notificationProviders:{descriptions:{email:"SMTP 郵件通知",telegram:"透過 Telegram 機器人通知",discord:"透過 Webhook 傳送到 Discord 頻道",ntfy:"免費、可自託管的推送通知",pushover:"簡單、可靠的推送通知",callmebot:"透過 CallMeBot 的免費 WhatsApp 通知",webhook:"通用 HTTP POST 到任意 URL"}},logViewer:{searchPlaceholder:"搜尋訊息或日誌名稱...",noLogEntries:"未找到日誌條目"},switchbarPopover:{noSwitchesInSwitchbar:"切換欄中沒有開關"},projectPageModal:{placeholders:{title:"標題",designer:"設計師",license:"許可證",description:"輸入描述...",profileTitle:"設定標題",profileDescription:"設定描述..."}},spoolmanSettings:{},time:{unknown:"-",waiting:"等待中",justNow:"剛剛",now:"現在",minsAgo:"{{count}} 分鐘前",inMins:"{{count}} 分鐘後",hoursAgo:"{{count}} 小時前",inHours:"{{count}} 小時後",daysAgo:"{{count}} 天前",inDays:"{{count}} 天後"},spoolbuddy:{nav:{dashboard:"儀表板",ams:"AMS",inventory:"庫存",writeTag:"寫入",settings:"設定"},status:{nfcReady:"NFC 就緒",nfcOff:"NFC 關閉",offline:"離線",online:"線上",noPrinters:"無印表機",deviceOffline:"裝置離線",waitingConnection:"等待裝置連線...",systemReady:"系統就緒",status:"狀態"},dashboard:{readyToScan:"準備掃描",idleMessage:"將耗材放在磅秤上以識別",nfcHint:"NFC 標籤將自動讀取",device:"裝置",syncWeight:"同步重量",weightSynced:"已同步!",unknownTag:"未知標籤",newTag:"偵測到新標籤",onScale:"在磅秤上",linkSpool:"連結到耗材",linkTagTitle:"將標籤連結到耗材",linkTag:"連結標籤",selectSpool:"選擇要連結此標籤的耗材:",noUntagged:"未找到沒有標籤的耗材",tagDetected:"偵測到標籤",noTag:"無標籤",tagId:"標籤",grossWeight:"毛重",spoolSize:"耗材盤尺寸",close:"關閉",currentSpool:"目前耗材",plateReady:"熱床就緒: {{name}}",plateReadyLabel:"待清理的熱床",plateClearAction:"清理",plateClearedToast:"已將熱床標記為已清理",plateClearFailed:"無法將熱床標記為已清理"},modal:{spoolDetected:"偵測到耗材",assignToAms:"分配到 AMS",syncWeight:"同步重量",weightSynced:"已同步!",syncing:"同步中...",newTagDetected:"偵測到新標籤",addToInventory:"新增到庫存",assignToAmsTitle:"分配到 AMS",selectSlot:"選擇槽位",assign:"分配",assigning:"分配中...",assignSuccess:"已分配!",assignPendingInsert:"已分配。插入耗材後將設定槽位。",assignError:"分配耗材失敗。請重試。",noPrinterSelected:"選擇印表機...",noAmsDetected:"此印表機未偵測到 AMS",slot:"槽位"},weight:{noReading:"無讀數",stable:"穩定",measuring:"測量中...",tare:"去皮",calibrate:"校準"},spool:{remaining:"剩餘",material:"材料",brand:"品牌",color:"顏色",coreWeight:"軸心重",labelWeight:"標籤重",scaleWeight:"磅秤重",netWeight:"淨重",lastUsed:"上次使用"},ams:{noData:"未偵測到 AMS",connectAms:"連線 AMS 以檢視耗材槽位",noPrinter:"未選擇印表機",selectPrinter:"從頂部欄選擇印表機",printerDisconnected:"印表機已斷開",humidity:"濕度",level:"餘量",active:"活躍",slot:"槽位",empty:"空"},inventory:{search:"搜尋耗材...",empty:"庫存中沒有耗材",noResults:"沒有匹配的耗材",spools:"個耗材",addSpool:"新增耗材"},settings:{tabDevice:"裝置",tabDisplay:"顯示",tabScale:"磅秤",tabUpdates:"更新",nfcReader:"NFC 讀卡器",type:"類型",connection:"連線",notConnected:"不適用",deviceInfo:"裝置資訊",hostname:"主機",uptime:"執行時間",systemConfig:"後端與認證",backendUrl:"Bambuddy 後端 URL",apiToken:"API 權杖",apiTokenPlaceholder:"輸入 API 權杖",saveConfig:"儲存設定",systemQueued:"設定已加入佇列。",nfcDiagnostic:"NFC 診斷",scaleDiagnostic:"磅秤診斷",readTagDiagnostic:"讀取標籤診斷",testNfc:"測試讀卡器",testScale:"測試精度",testReadTag:"讀取標籤",systemFieldsRequired:"後端 URL 為必填項。",brightness:"亮度",saved:"已儲存",noBacklight:"未偵測到 DSI 背光。亮度控制需要 DSI 螢幕。",screenBlank:"螢幕熄滅超時",screenBlankDesc:"不活動後螢幕關閉。觸控喚醒。",displayNote:"亮度作為軟體濾鏡套用。",scaleCalibration:"磅秤校準",currentWeight:"目前重量",tareOffset:"去皮",calFactor:"係數",knownWeight:"已知重量",calStep1:"移除磅秤上所有物品並按設定零點。",calStep2:"將已知重量放在磅秤上。",setZero:"設定零點",calibrateNow:"校準",calibrated:"已校準",tareSet:"去皮命令已傳送。等待裝置回應...",tareFailed:"傳送去皮命令失敗",zeroSet:"零點已設定。將已知重量放在磅秤上。",calibrationDone:"校準完成!",calibrationFailed:"校準失敗",lastCalibrated:"上次校準",stable:"穩定",settling:"穩定中...",firmware:"韌體",scale:"磅秤",noDevice:"未找到 SpoolBuddy 裝置",daemonVersion:"守護程式版本",currentVersion:"目前",versionPending:"等待守護程式...",checking:"檢查中...",checkUpdates:"檢查更新",updateAvailable:"有可用更新",updateInstructions:"透過 SSH 更新:執行 SpoolBuddy 安裝腳本進行升級。",upToDate:"已是最新",includeBeta:"包含測試版本"},writeTag:{tabExisting:"現有耗材",tabNew:"新耗材",tabReplace:"替換標籤",searchPlaceholder:"按材料、顏色、品牌搜尋...",noUntaggedSpools:"沒有無標籤的耗材",noTaggedSpools:"沒有有標籤的耗材",selectSpool:"選擇一個耗材,然後將空白 NTAG 放在讀卡器上",placeTag:"將 NTAG 放在讀卡器上",tagReady:"偵測到標籤 — 準備寫入",writeTag:"寫入標籤",replaceTag:"替換標籤",writing:"寫入標籤中...",waiting:"等待 SpoolBuddy...",writeSuccess:"標籤寫入成功!",writeFailed:"寫入失敗",queueFailed:"佇列寫入命令失敗",tryAgain:"重試",cancel:"取消",replaceWarning:"舊標籤將被取消連結。新標籤將替換它。",deviceOffline:"SpoolBuddy 離線",material:"材料",colorName:"顏色名稱",color:"顏色",brand:"品牌",weight:"重量 (g)",createSpool:"建立耗材",creating:"建立中...",spoolCreated:"耗材已建立!準備寫入。",createFailed:"建立耗材失敗",incompleteDataWarning:"已使用不完整的Spoolman資料寫入標籤"},quickMenu:{printerPower:"印表機電源",systemControls:"系統",restartDaemon:"重新啟動守護程式",restartBrowser:"重新啟動瀏覽器",reboot:"重新開機",shutdown:"關機",swipeToClose:"向下滑動關閉",confirmTitle:"確認",confirmShutdown:"確定要關閉 SpoolBuddy 嗎?您需要實體存取才能重新開啟。",confirmReboot:"確定要重新開機 SpoolBuddy 嗎?",confirmRestartDaemon:"重新啟動 SpoolBuddy 守護程式?NFC 和磅秤將暫時不可用。",confirmRestartBrowser:"重新啟動kiosk瀏覽器?螢幕將短暫變黑。",confirm:"確認",confirmPlugOn:"開啟 {{name}}?",confirmPlugOff:"關閉 {{name}}?",turnOn:"開啟",turnOff:"關閉"}},bugReport:{title:"報告錯誤",description:"描述",descriptionPlaceholder:"出了什麼問題?請描述問題...",email:"信箱(可選)",emailPlaceholder:"your@email.com",emailPrivacy:"如果提供,您的信箱將包含在GitHub Issue的摺疊部分中,以便維護者後續跟進。",screenshot:"截圖",uploadOrPaste:"上傳、貼上或拖曳圖片",dataCollectedSummary:"報告中包含哪些資料?",dataIncluded:"包含:",dataIncludedList:"應用程式版本、作業系統、架構、Python版本、資料庫統計(僅計數)、印表機型號、噴嘴數量、韌體版本、連線狀態、整合狀態(Spoolman、MQTT、HA)、非敏感設定、網路介面數量、Docker詳情、依賴版本。",dataNeverIncluded:"絕不包含:",dataNeverIncludedList:"印表機名稱、序列號、存取碼、密碼、IP 位址、信箱地址、API金鑰、權杖、Webhook URL、主機名稱或使用者名稱。",submit:"提交",startLogging:"開始偵錯日誌",stepEnableLogging:"偵錯日誌已啟用",stepReproduce:"請現在重現問題",stepStopLogging:"停止並提交報告",stopAndSubmit:"停止並提交",maxDuration:"{{minutes}} 分鐘後自動停止",stoppingLogs:"正在收集日誌並提交...",submitting:"正在提交錯誤報告...",submitSuccess:"錯誤報告提交成功!",submitFailed:"提交錯誤報告失敗",thankYou:"謝謝!",submitted:"您的錯誤報告已提交。",viewIssue:"檢視 Issue",unexpectedError:"發生了意外錯誤"},failureDetection:{title:"AI 故障檢測",description:"透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。",mlUrl:"Obico ML API 地址",mlUrlHint:"您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。",test:"測試",testSuccess:"ML API 可存取且正常。",testFailed:"無法存取 ML API。",sensitivity:"靈敏度",sensitivityLow:"低(減少誤報)",sensitivityMedium:"中(平衡)",sensitivityHigh:"高(更早檢測,更多誤報)",sensitivityHint:"調整觸發警告和故障的置信度閾值。",action:"偵測到故障時的操作",actionNotify:"僅通知",actionPause:"暫停列印",actionPauseOff:"暫停並切斷電源",pollInterval:"檢查間隔(秒)",pollIntervalHint:"列印過程中每臺印表機的檢查頻率。最小 5 秒,最大 120 秒。",externalUrlMissing:"尚未設定外部 URL。",externalUrlHint:"ML API 透過 URL 擷取攝影機快照。請在一般設定中設定外部 URL,讓 ML API 容器可以連線到 Bambuddy。",perPrinterTitle:"監控的印表機",perPrinterHint:"選擇檢測服務要監視哪些印表機。",monitorAll:"監控所有已連線的印表機",statusTitle:"狀態",serviceRunning:"服務執行中",thresholds:"低 / 高閾值",activePrinters:"活動列印",noActivePrints:"目前沒有正在進行的列印。",historyTitle:"最近檢測",noHistory:"尚無檢測紀錄。"},makerworld:{title:"MakerWorld",description:"貼上 MakerWorld 模型連結,即可直接在 Bambuddy 中匯入並列印 —— 無需切換至 Bambu Handy 應用程式。",pasteUrlHeader:"從 MakerWorld 匯入",pasteUrlPlaceholder:"https://makerworld.com/en/models/… 或貼上任意 MakerWorld 連結",resolveButton:"解析",signInRequiredTitle:"下載需要登入 Bambu Cloud",signInRequiredBody:"您可以匿名瀏覽模型詳情,但下載 3MF 檔案需要 Bambu Cloud 帳戶。",openCloudSettings:"開啟雲端設定",untitledModel:"無標題模型",byCreator:"作者: {{name}}",downloadsCount:"{{count}} 次下載",licensePrefix:"授權條款",alreadyImported:"已在資料庫中",openOnMakerworld:"在 MakerWorld 中開啟",alreadyInLibrary:"此模型已在您的資料庫中 —— 可在檔案管理員 → MakerWorld 中找到",importSuccess:"已匯入 {{filename}} —— 已儲存至檔案管理員 → MakerWorld",platesHeader:"列印板 ({{count}})",plateDefaultName:"列印板 {{n}}",materialCount:"{{count}} 種耗材",amsRequired:"需要 AMS",slicedFor:"Sliced for {{printer}}",alsoCompatible:"Also marked compatible: {{printers}}",importToLibrary:"儲存",sliceIn:"儲存並在 {{slicer}} 中切片",disclaimer:"MakerWorld 整合使用由社群記錄的 API 介面。Bambuddy 與 MakerWorld 或 Bambu Lab 無從屬或認可關係。",lastImportSuccess:"已匯入您的資料庫",lastImportAlreadyInLibrary:"已存在於您的資料庫中",viewInLibrary:"在檔案管理員中查看",openInBambuStudio:"在 Bambu Studio 中開啟",openInOrcaSlicer:"在 OrcaSlicer 中開啟",importTo:"匯入至檔案管理員",recentImportsHeader:"最近匯入",phaseResolving:"解析中",phaseDownloading:"下載中",folderAuto:"MakerWorld (預設)",importAll:"全部匯入",importAllProgress:"正在匯入 {{current}}/{{total}}",openGallery:"開啟圖片庫",galleryPrev:"上一張",galleryNext:"下一張",deleteImport:"從資料庫中移除",importDeleting:"正在移除…",importDeleted:"已從資料庫中移除",confirmDelete:"從資料庫中移除 {{filename}}?本機檔案將被刪除,但可以從 MakerWorld 重新匯入。",errors:{resolveFailed:"無法解析該 MakerWorld 連結。",downloadFailed:"下載失敗。請重試。",deleteFailed:"無法從資料庫中移除檔案。"}},gcodeViewer:{back:"返回",backToArchives:"返回列印歸檔",backToFiles:"返回檔案管理器"},libraryTrash:{title:"資源回收筒",headerButton:"資源回收筒",headerTooltip:"檢視已移至資源回收筒的檔案",backToFiles:"返回檔案管理員",subtitleAdmin:"已刪除的檔案會在資源回收筒保留 {{days}} 天,之後自動刪除。此檢視顯示所有使用者的資源回收筒。",subtitleUser:"已刪除的檔案會在資源回收筒保留 {{days}} 天,之後自動刪除。",loading:"正在載入資源回收筒…",loadError:"無法載入資源回收筒。",empty:"資源回收筒是空的。",summary:"{{count}} 個檔案 · {{size}}",emptyTrash:"清空資源回收筒",restore:"還原",purgeNow:"立即刪除",autoPurgeIn:"將於 {{when}} 自動刪除",days:"天",retentionLabel:"自動刪除時間",selectAll:"全選",selectOne:"選擇 {{filename}}",selectionCount:"已選擇 {{count}} 項",bulkRestore:"還原所選",bulkPurge:"刪除所選",col:{filename:"檔案",folder:"資料夾",size:"大小",deleted:"移入資源回收筒",autoPurge:"自動刪除",owner:"擁有者",actions:"操作"},confirm:{purgeTitle:"永久刪除?",purgeBody:"{{filename}} 將從磁碟中刪除,無法還原。",emptyTitle:"清空資源回收筒?",emptyBody:"全部 {{count}} 個檔案將從磁碟中刪除。此操作無法復原。",bulkPurgeTitle:"永久刪除所選檔案?",bulkPurgeBody:"所選的 {{count}} 個檔案將從磁碟中刪除,無法還原。",cta:"永久刪除"},toast:{restored:"檔案已還原。",restoreFailed:"無法還原檔案。",purged:"檔案已永久刪除。",purgeFailed:"無法刪除檔案。",emptied:"已從資源回收筒刪除 {{count}} 個檔案。",emptyFailed:"無法清空資源回收筒。",retentionSaved:"自動刪除已設定為 {{days}} 天。",retentionFailed:"無法儲存保留設定。",bulkRestored:"已還原 {{count}} 個檔案。",bulkPurged:"已刪除 {{count}} 個檔案。"}},libraryPurge:{title:"清理舊檔案",headerButton:"清理舊檔案",headerTooltip:"批次將舊檔案移至資源回收筒",description:"早於所選時間的檔案將被移至資源回收筒。外部資料夾會被略過。在自動刪除之前,您可以從資源回收筒還原檔案。",ageLabel:"移動早於以下天數的檔案",days:"天",includeNeverPrinted:"包括從未列印過的檔案",effectsTitle:"What happens when you click Purge",effect1:"Matching files are moved to Trash — they are not deleted from disk yet.",effect2:"You can restore them from Trash at any time until the retention window expires.",effect3:"After retention, the trash sweeper permanently removes them from disk.",effect4:"Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.",previewLoading:"正在檢查符合的檔案數量…",previewFailed:"無法預覽清理結果。",previewSummary:"{{count}} 個檔案 · {{size}} 將被移至資源回收筒",andMore:"…還有 {{count}} 個",warning:"檔案將被軟刪除——在保留期結束前,您可以從資源回收筒還原它們。",confirmCta:"將 {{count}} 個移至資源回收筒",purging:"正在移至資源回收筒…",toast:{success:"已將 {{count}} 個檔案移至資源回收筒。",failed:"無法清理檔案。"}},libraryAutoPurge:{enableLabel:"自動清理舊檔案",enableDescription:"每天執行一次管理員清理。檔案會先進入資源回收筒——不會立即刪除。",ageLabel:"自動清理早於以下天數的檔案",ageDescription:"最少 7 天,最多 10 年。使用與手動「清理舊檔案」按鈕相同的時間規則。",days:"天",includeNeverPrinted:"包括從未列印過的檔案",saveFailed:"無法儲存自動清理設定。"},archivePurge:{headerButton:"Purge old",headerTooltip:"Bulk-delete old archives",title:"Purge old archives",description:"Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.",ageLabel:"Delete archives not printed in the last",days:"days",effectsTitle:"What happens when you click Purge",effect1:"Each matching archive is permanently removed from the database.",effect2:"The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.",effect3:"There is no trash bin for archives — deletion is immediate and cannot be undone.",effect4:"Reprinting an archive refreshes its age clock, so archives you still use are safe.",previewLoading:"Checking how many archives match…",previewFailed:"Could not preview the purge.",previewSummary:"{{count}} archives · {{size}} would be deleted",andMore:"…and {{count}} more",warning:"This is permanent. Download or favourite anything you want to keep before continuing.",confirmCta:"Delete {{count}} archive(s)",purging:"Deleting…",toast:{success:"Deleted {{count}} archive(s).",failed:"Could not purge archives."}},archiveAutoPurge:{enableLabel:"Auto-purge old archives",enableDescription:"Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.",ageLabel:"Auto-delete archives not printed in the last",ageDescription:"Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.",days:"days",runNow:"Purge archives now",saveFailed:"Could not save auto-purge settings."},cameraTokens:{title:"Camera API Tokens",navTitle:"Camera API tokens",description:"Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.",loading:"Loading…",confirmRevoke:{title:"Revoke this token?",body:'Any device using "{{name}}" will lose access immediately. This cannot be undone.',cancel:"Cancel",confirm:"Revoke"},create:{title:"Create new token",nameLabel:"Token name",namePlaceholder:"e.g. Home Assistant",daysLabel:"Days until expiry",submit:"Create",hint:"Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now."},created:{title:"Token created — copy it now",warning:"This is the only time this token will be visible. After you close this dialog you can never view it again.",copy:"Copy",dismiss:"I've saved it"},list:{myTitle:"My tokens",allTitle:"All users (admin view)",empty:"No tokens yet.",name:"Name",owner:"Owner",prefix:"Prefix",created:"Created",expires:"Expires",lastUsed:"Last used",revoke:"Revoke",expired:"Expired"},toast:{created:"Token created",createFailed:"Failed to create token",revoked:"Token revoked",revokeFailed:"Failed to revoke token",loadFailed:"Failed to load tokens",copied:"Copied to clipboard",copyFailed:"Copy failed — select and copy manually"}},forecast:{title:"庫存預測",noSpools:"未找到活躍的料卷。請將料卷新增至庫存以查看預測資料。",noUsageData:"無可用使用資料 — 無法預測庫存時間線。",sku:"SKU",material:"材料",stock:"庫存",dailyRate:"消耗率",daysLeft:"剩餘天數",emptyBy:"耗盡日期",reorderBy:"補貨截止日",actions:"操作",trend:"趨勢",estimated:"估算",noData:"無資料",timeframe:"時間範圍",chartTitle:"庫存預測 — 前5種材料",dashedLinesROP:"虛線 = 再訂貨點",stockLevel:"庫存量",reorderPoint:"再訂購點",safetyMargin:"安全餘量",trendLegend:"趨勢(基於歷史,95%服務水準)",estimatedLegend:"估算(重量差值)",noDataLegend:"無資料",ropLabel:"再訂貨點",ssLabel:"安全庫存",safetyStockLegend:"安全庫存",stockArrivalLegend:"到貨",stockoutLegend:"斷貨",alertCount_one:"{{count}}條警告",alertCount_other:"{{count}}條警告",order:"訂購",globalLeadTime:"全域交貨期",globalLeadTimeHint:"全域交貨期下限 — 用於所有 SKU 的再訂購點計算",save:"儲存",cancel:"取消",settingsSaved:"設定已儲存",failedSaveSettings:"儲存設定失敗",globalLeadTimeSaved:"全域提前期已儲存",skuLeadTimeOverride:"SKU 交貨期覆蓋",skuLeadTimeHint:"0 = 使用全域交貨期。設定 >0 以覆蓋此 SKU。",safetyMarginLabel:"安全餘量",effectiveLeadTime:"有效交貨期",effectiveLeadTimeHint:"max(全域 {{global}}天, SKU {{sku}}天)",reorderPointHint:"d̄ × LT + safety margin — 當庫存降至此水準時下單",safetyMarginHint:"統計安全庫存 (z=1.65 × σ × √LT) + 使用者自訂緩衝",safetyMarginHintDays:"在統計安全庫存基礎上額外增加的緩衝。{{approx}}",safetyMarginHintDaysApprox:" ≈ 按當前速率 {{g}}g。",safetyMarginHintG:"在統計安全庫存基礎上增加的固定重量緩衝。{{approx}}",safetyMarginHintGApprox:" ≈ 按當前速率 {{days}}天。",individualSpools:"單個料卷",labelWeight:"標示重量",spoolCount_one:"{{count}}個線軸",spoolCount_other:"{{count}}個線軸",stockBreakRisk:"斷貨風險",stockBreakBefore:"補貨前庫存斷檔",stockBreakDetail:"剩餘 {{days}} 天,交貨期 {{lt}} 天。",reorderNow:"立即補貨",reorderTriggerPassed:"觸發日期 {{date}} 已過。",shoppingList:"購物清單",shoppingListItems_one:"({{count}}項)",shoppingListItems_other:"({{count}}項)",shoppingListEmpty:"購物清單為空。點擊任意列的購物車圖示以新增商品。",addToCart:"新增至購物清單",alertsSnoozed:"靜音此SKU的提醒",alertsEnabled:"啟用此SKU的提醒",addedToCart:"已新增至購物清單",failedAddItem:"新增項目失敗",listView:"列表",logisticsView:"物流",qty:"數量",weight:"重量",leadTime:"交貨期",expectedRestock:"預計補貨日期",status:"狀態",note:"備註",pending:"待處理",purchased:"已購買",received:"已收貨",markPurchased:"標記為已購買",markReceived:"標記為已收貨 — 將料卷新增至庫存",resetToPending:"重置為待處理",remove:"移除",clearAll:"清空",downloadCsv:"CSV",addToCartTitle:"新增至購物清單",byQuantity:"依數量",byDuration:"依時長",numberOfSpools:"線軸數量",lastHowManyDays:"需要持續多少天?",noUsageQty:"無使用資料 — 數量已設為1。",noteOptional:"備註(選填)",notePlaceholder:"例如:用於專案X,緊急…",addNSpools_one:"新增{{count}}個線軸",addNSpools_other:"新增{{count}}個線軸",onArrival:"到貨時",stockBreakIn:"{{days}} 天後斷貨。",stockRunsOutBefore:"庫存在 {{lt}} 天交貨期結束前耗盡。",atRate:"按 {{rate}}g/天的速度,您需要",moreSpools_one:"再{{count}}個線軸",moreSpools_other:"再{{count}}個線軸",bridgeGap:"來彌補缺口。",noReadAccess:"您沒有查看庫存預測的權限。",noWriteAccess:"您沒有修改預測設定的權限。"}},$me={en:{translation:Lme},de:{translation:Ime},fr:{translation:Ome},ja:{translation:zme},it:{translation:Ume},"pt-BR":{translation:Bme},"zh-CN":{translation:Hme},"zh-TW":{translation:qme}};To.use(BQ).use(hme).init({resources:$me,fallbackLng:"en",supportedLngs:["en","de","fr","ja","it","pt-BR","zh-CN","zh-TW"],detection:{order:["localStorage","navigator","htmlTag"],lookupLocalStorage:"bambutrack_language",caches:["localStorage"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const qH=[{code:"en",name:"English",nativeName:"English"},{code:"de",name:"German",nativeName:"Deutsch"},{code:"fr",name:"French",nativeName:"Français"},{code:"ja",name:"Japanese",nativeName:"日本語"},{code:"it",name:"Italian",nativeName:"Italiano"},{code:"pt-BR",name:"Portuguese (Brazil)",nativeName:"Português (Brasil)"},{code:"zh-CN",name:"Chinese (Simplified)",nativeName:"简体中文"},{code:"zh-TW",name:"Chinese (Traditional)",nativeName:"繁體中文"}];var $H="popstate";function Vme(t={}){function e(a,i){let{pathname:s,search:o,hash:l}=a.location;return PF("",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||"default")}function n(a,i){return typeof i=="string"?i:_w(i)}return Wme(e,n,null,t)}function za(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function Sc(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function Gme(){return Math.random().toString(36).substring(2,10)}function VH(t,e){return{usr:t.state,key:t.key,idx:e}}function PF(t,e,n=null,a){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?zy(e):e,state:n,key:e&&e.key||a||Gme()}}function _w({pathname:t="/",search:e="",hash:n=""}){return e&&e!=="?"&&(t+=e.charAt(0)==="?"?e:"?"+e),n&&n!=="#"&&(t+=n.charAt(0)==="#"?n:"#"+n),t}function zy(t){let e={};if(t){let n=t.indexOf("#");n>=0&&(e.hash=t.substring(n),t=t.substring(0,n));let a=t.indexOf("?");a>=0&&(e.search=t.substring(a),t=t.substring(0,a)),t&&(e.pathname=t)}return e}function Wme(t,e,n,a={}){let{window:i=document.defaultView,v5Compat:s=!1}=a,o=i.history,l="POP",c=null,d=u();d==null&&(d=0,o.replaceState({...o.state,idx:d},""));function u(){return(o.state||{idx:null}).idx}function m(){l="POP";let g=u(),x=g==null?null:g-d;d=g,c&&c({action:l,location:w.location,delta:x})}function p(g,x){l="PUSH";let _=PF(w.location,g,x);d=u()+1;let N=VH(_,d),C=w.createHref(_);try{o.pushState(N,"",C)}catch(P){if(P instanceof DOMException&&P.name==="DataCloneError")throw P;i.location.assign(C)}s&&c&&c({action:l,location:w.location,delta:1})}function f(g,x){l="REPLACE";let _=PF(w.location,g,x);d=u();let N=VH(_,d),C=w.createHref(_);o.replaceState(N,"",C),s&&c&&c({action:l,location:w.location,delta:0})}function b(g){return Kme(g)}let w={get action(){return l},get location(){return t(i,o)},listen(g){if(c)throw new Error("A history only accepts one active listener");return i.addEventListener($H,m),c=g,()=>{i.removeEventListener($H,m),c=null}},createHref(g){return e(i,g)},createURL:b,encodeLocation(g){let x=b(g);return{pathname:x.pathname,search:x.search,hash:x.hash}},push:p,replace:f,go(g){return o.go(g)}};return w}function Kme(t,e=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),za(n,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:_w(t);return a=a.replace(/ $/,"%20"),!e&&a.startsWith("//")&&(a=n+a),new URL(a,n)}function HQ(t,e,n="/"){return Yme(t,e,n,!1)}function Yme(t,e,n,a){let i=typeof e=="string"?zy(e):e,s=Bm(i.pathname||"/",n);if(s==null)return null;let o=qQ(t);Xme(o);let l=null;for(let c=0;l==null&&c{let u={relativePath:d===void 0?o.path||"":d,caseSensitive:o.caseSensitive===!0,childrenIndex:l,route:o};if(u.relativePath.startsWith("/")){if(!u.relativePath.startsWith(a)&&c)return;za(u.relativePath.startsWith(a),`Absolute route path "${u.relativePath}" nested under path "${a}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(a.length)}let m=Mm([a,u.relativePath]),p=n.concat(u);o.children&&o.children.length>0&&(za(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),qQ(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:rpe(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of $Q(o.path))s(o,l,!0,c)}),e}function $Q(t){let e=t.split("/");if(e.length===0)return[];let[n,...a]=e,i=n.endsWith("?"),s=n.replace(/\?$/,"");if(a.length===0)return i?[s,""]:[s];let o=$Q(a.join("/")),l=[];return l.push(...o.map(c=>c===""?s:[s,c].join("/"))),i&&l.push(...o),l.map(c=>t.startsWith("/")&&c===""?"/":c)}function Xme(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:ape(e.routesMeta.map(a=>a.childrenIndex),n.routesMeta.map(a=>a.childrenIndex)))}var Qme=/^:[\w-]+$/,Zme=3,Jme=2,epe=1,tpe=10,npe=-2,GH=t=>t==="*";function rpe(t,e){let n=t.split("/"),a=n.length;return n.some(GH)&&(a+=npe),e&&(a+=Jme),n.filter(i=>!GH(i)).reduce((i,s)=>i+(Qme.test(s)?Zme:s===""?epe:tpe),a)}function ape(t,e){return t.length===e.length&&t.slice(0,-1).every((a,i)=>a===e[i])?t[t.length-1]-e[e.length-1]:0}function ipe(t,e,n=!1){let{routesMeta:a}=t,i={},s="/",o=[];for(let l=0;l{if(u==="*"){let b=l[p]||"";o=s.slice(0,s.length-b.length).replace(/(.)\/+$/,"$1")}const f=l[p];return m&&!f?d[u]=void 0:d[u]=(f||"").replace(/%2F/g,"/"),d},{}),pathname:s,pathnameBase:o,pattern:t}}function spe(t,e=!1,n=!0){Sc(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let a=[],i="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(a.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(a.push({paramName:"*"}),i+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?i+="\\/*$":t!==""&&t!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,e?void 0:"i"),a]}function ope(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return Sc(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${e}).`),t}}function Bm(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let n=e.endsWith("/")?e.length-1:e.length,a=t.charAt(n);return a&&a!=="/"?null:t.slice(n)||"/"}var lpe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function cpe(t,e="/"){let{pathname:n,search:a="",hash:i=""}=typeof t=="string"?zy(t):t,s;return n?(n=n.replace(/\/\/+/g,"/"),n.startsWith("/")?s=WH(n.substring(1),"/"):s=WH(n,e)):s=e,{pathname:s,search:mpe(a),hash:ppe(i)}}function WH(t,e){let n=e.replace(/\/+$/,"").split("/");return t.split("/").forEach(i=>{i===".."?n.length>1&&n.pop():i!=="."&&n.push(i)}),n.length>1?n.join("/"):"/"}function eE(t,e,n,a){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(a)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function dpe(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function ZI(t){let e=dpe(t);return e.map((n,a)=>a===e.length-1?n.pathname:n.pathnameBase)}function JI(t,e,n,a=!1){let i;typeof t=="string"?i=zy(t):(i={...t},za(!i.pathname||!i.pathname.includes("?"),eE("?","pathname","search",i)),za(!i.pathname||!i.pathname.includes("#"),eE("#","pathname","hash",i)),za(!i.search||!i.search.includes("#"),eE("#","search","hash",i)));let s=t===""||i.pathname==="",o=s?"/":i.pathname,l;if(o==null)l=n;else{let m=e.length-1;if(!a&&o.startsWith("..")){let p=o.split("/");for(;p[0]==="..";)p.shift(),m-=1;i.pathname=p.join("/")}l=m>=0?e[m]:"/"}let c=cpe(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var Mm=t=>t.join("/").replace(/\/\/+/g,"/"),upe=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),mpe=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,ppe=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,hpe=class{constructor(t,e,n,a=!1){this.status=t,this.statusText=e||"",this.internal=a,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function fpe(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function gpe(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var VQ=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function GQ(t,e){let n=t;if(typeof n!="string"||!lpe.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let a=n,i=!1;if(VQ)try{let s=new URL(window.location.href),o=n.startsWith("//")?new URL(s.protocol+n):new URL(n),l=Bm(o.pathname,e);o.origin===s.origin&&l!=null?n=l+o.search+o.hash:i=!0}catch{Sc(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:a,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var WQ=["POST","PUT","PATCH","DELETE"];new Set(WQ);var bpe=["GET",...WQ];new Set(bpe);var Uy=v.createContext(null);Uy.displayName="DataRouter";var fT=v.createContext(null);fT.displayName="DataRouterState";var xpe=v.createContext(!1),KQ=v.createContext({isTransitioning:!1});KQ.displayName="ViewTransition";var ype=v.createContext(new Map);ype.displayName="Fetchers";var vpe=v.createContext(null);vpe.displayName="Await";var El=v.createContext(null);El.displayName="Navigation";var yS=v.createContext(null);yS.displayName="Location";var Mc=v.createContext({outlet:null,matches:[],isDataRoute:!1});Mc.displayName="Route";var eO=v.createContext(null);eO.displayName="RouteError";var YQ="REACT_ROUTER_ERROR",wpe="REDIRECT",Spe="ROUTE_ERROR_RESPONSE";function _pe(t){if(t.startsWith(`${YQ}:${wpe}:{`))try{let e=JSON.parse(t.slice(28));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.location=="string"&&typeof e.reloadDocument=="boolean"&&typeof e.replace=="boolean")return e}catch{}}function kpe(t){if(t.startsWith(`${YQ}:${Spe}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new hpe(e.status,e.statusText,e.data)}catch{}}function Npe(t,{relative:e}={}){za(By(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:a}=v.useContext(El),{hash:i,pathname:s,search:o}=wS(t,{relative:e}),l=s;return n!=="/"&&(l=s==="/"?n:Mm([n,s])),a.createHref({pathname:l,search:o,hash:i})}function By(){return v.useContext(yS)!=null}function ud(){return za(By(),"useLocation() may be used only in the context of a component."),v.useContext(yS).location}var XQ="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function QQ(t){v.useContext(El).static||v.useLayoutEffect(t)}function Mi(){let{isDataRoute:t}=v.useContext(Mc);return t?zpe():Cpe()}function Cpe(){za(By(),"useNavigate() may be used only in the context of a component.");let t=v.useContext(Uy),{basename:e,navigator:n}=v.useContext(El),{matches:a}=v.useContext(Mc),{pathname:i}=ud(),s=JSON.stringify(ZI(a)),o=v.useRef(!1);return QQ(()=>{o.current=!0}),v.useCallback((c,d={})=>{if(Sc(o.current,XQ),!o.current)return;if(typeof c=="number"){n.go(c);return}let u=JI(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:Mm([e,u.pathname])),(d.replace?n.replace:n.push)(u,d.state,d)},[e,n,s,i,t])}var ZQ=v.createContext(null);function Hy(){return v.useContext(ZQ)}function Ppe(t){let e=v.useContext(Mc).outlet;return v.useMemo(()=>e&&v.createElement(ZQ.Provider,{value:t},e),[e,t])}function vS(){let{matches:t}=v.useContext(Mc),e=t[t.length-1];return e?e.params:{}}function wS(t,{relative:e}={}){let{matches:n}=v.useContext(Mc),{pathname:a}=ud(),i=JSON.stringify(ZI(n));return v.useMemo(()=>JI(t,JSON.parse(i),a,e==="path"),[t,i,a,e])}function Tpe(t,e){return JQ(t,e)}function JQ(t,e,n,a,i){za(By(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=v.useContext(El),{matches:o}=v.useContext(Mc),l=o[o.length-1],c=l?l.params:{},d=l?l.pathname:"/",u=l?l.pathnameBase:"/",m=l&&l.route;{let _=m&&m.path||"";tZ(d,!m||_.endsWith("*")||_.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${d}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let p=ud(),f;if(e){let _=typeof e=="string"?zy(e):e;za(u==="/"||_.pathname?.startsWith(u),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${u}" but pathname "${_.pathname}" was given in the \`location\` prop.`),f=_}else f=p;let b=f.pathname||"/",w=b;if(u!=="/"){let _=u.replace(/^\//,"").split("/");w="/"+b.replace(/^\//,"").split("/").slice(_.length).join("/")}let g=HQ(t,{pathname:w});Sc(m||g!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),Sc(g==null||g[g.length-1].route.element!==void 0||g[g.length-1].route.Component!==void 0||g[g.length-1].route.lazy!==void 0,`Matched leaf route at location "${f.pathname}${f.search}${f.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let x=Dpe(g&&g.map(_=>Object.assign({},_,{params:Object.assign({},c,_.params),pathname:Mm([u,s.encodeLocation?s.encodeLocation(_.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathname]),pathnameBase:_.pathnameBase==="/"?u:Mm([u,s.encodeLocation?s.encodeLocation(_.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathnameBase])})),o,n,a,i);return e&&x?v.createElement(yS.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},x):x}function Ape(){let t=Ope(),e=fpe(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),n=t instanceof Error?t.stack:null,a="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:a},s={padding:"2px 4px",backgroundColor:a},o=null;return console.error("Error handled by React Router default ErrorBoundary:",t),o=v.createElement(v.Fragment,null,v.createElement("p",null,"💿 Hey developer 👋"),v.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",v.createElement("code",{style:s},"ErrorBoundary")," or"," ",v.createElement("code",{style:s},"errorElement")," prop on your route.")),v.createElement(v.Fragment,null,v.createElement("h2",null,"Unexpected Application Error!"),v.createElement("h3",{style:{fontStyle:"italic"}},e),n?v.createElement("pre",{style:i},n):null,o)}var jpe=v.createElement(Ape,null),eZ=class extends v.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,e){return e.location!==t.location||e.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:e.error,location:e.location,revalidation:t.revalidation||e.revalidation}}componentDidCatch(t,e){this.props.onError?this.props.onError(t,e):console.error("React Router caught the following error during render",t)}render(){let t=this.state.error;if(this.context&&typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){const n=kpe(t.digest);n&&(t=n)}let e=t!==void 0?v.createElement(Mc.Provider,{value:this.props.routeContext},v.createElement(eO.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?v.createElement(Mpe,{error:t},e):e}};eZ.contextType=xpe;var tE=new WeakMap;function Mpe({children:t,error:e}){let{basename:n}=v.useContext(El);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let a=_pe(e.digest);if(a){let i=tE.get(e);if(i)throw i;let s=GQ(a.location,n);if(VQ&&!tE.get(e))if(s.isExternal||a.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:a.replace}));throw tE.set(e,o),o}return v.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function Epe({routeContext:t,match:e,children:n}){let a=v.useContext(Uy);return a&&a.static&&a.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(a.staticContext._deepestRenderedBoundaryId=e.route.id),v.createElement(Mc.Provider,{value:t},n)}function Dpe(t,e=[],n=null,a=null,i=null){if(t==null){if(!n)return null;if(n.errors)t=n.matches;else if(e.length===0&&!n.initialized&&n.matches.length>0)t=n.matches;else return null}let s=t,o=n?.errors;if(o!=null){let u=s.findIndex(m=>m.route.id&&o?.[m.route.id]!==void 0);za(u>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(o).join(",")}`),s=s.slice(0,Math.min(s.length,u+1))}let l=!1,c=-1;if(n)for(let u=0;u=0?s=s.slice(0,c+1):s=[s[0]];break}}}let d=n&&a?(u,m)=>{a(u,{location:n.location,params:n.matches?.[0]?.params??{},unstable_pattern:gpe(n.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,b=!1,w=null,g=null;n&&(f=o&&m.route.id?o[m.route.id]:void 0,w=m.route.errorElement||jpe,l&&(c<0&&p===0?(tZ("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),b=!0,g=null):c===p&&(b=!0,g=m.route.hydrateFallbackElement||null)));let x=e.concat(s.slice(0,p+1)),_=()=>{let N;return f?N=w:b?N=g:m.route.Component?N=v.createElement(m.route.Component,null):m.route.element?N=m.route.element:N=u,v.createElement(Epe,{match:m,routeContext:{outlet:u,matches:x,isDataRoute:n!=null},children:N})};return n&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?v.createElement(eZ,{location:n.location,revalidation:n.revalidation,component:w,error:f,children:_(),routeContext:{outlet:null,matches:x,isDataRoute:!0},onError:d}):_()},null)}function tO(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Fpe(t){let e=v.useContext(Uy);return za(e,tO(t)),e}function Rpe(t){let e=v.useContext(fT);return za(e,tO(t)),e}function Lpe(t){let e=v.useContext(Mc);return za(e,tO(t)),e}function nO(t){let e=Lpe(t),n=e.matches[e.matches.length-1];return za(n.route.id,`${t} can only be used on routes that contain a unique "id"`),n.route.id}function Ipe(){return nO("useRouteId")}function Ope(){let t=v.useContext(eO),e=Rpe("useRouteError"),n=nO("useRouteError");return t!==void 0?t:e.errors?.[n]}function zpe(){let{router:t}=Fpe("useNavigate"),e=nO("useNavigate"),n=v.useRef(!1);return QQ(()=>{n.current=!0}),v.useCallback(async(i,s={})=>{Sc(n.current,XQ),n.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var KH={};function tZ(t,e,n){!e&&!KH[t]&&(KH[t]=!0,Sc(!1,n))}v.memo(Upe);function Upe({routes:t,future:e,state:n,onError:a}){return JQ(t,void 0,n,a,e)}function fg({to:t,replace:e,state:n,relative:a}){za(By()," may be used only in the context of a component.");let{static:i}=v.useContext(El);Sc(!i," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:s}=v.useContext(Mc),{pathname:o}=ud(),l=Mi(),c=JI(t,ZI(s),o,a==="path"),d=JSON.stringify(c);return v.useEffect(()=>{l(JSON.parse(d),{replace:e,state:n,relative:a})},[l,d,a,e,n]),null}function nZ(t){return Ppe(t.context)}function Cr(t){za(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Bpe({basename:t="/",children:e=null,location:n,navigationType:a="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){za(!By(),"You cannot render a inside another . You should never have more than one in your app.");let l=t.replace(/^\/*/,"/"),c=v.useMemo(()=>({basename:l,navigator:i,static:s,unstable_useTransitions:o,future:{}}),[l,i,s,o]);typeof n=="string"&&(n=zy(n));let{pathname:d="/",search:u="",hash:m="",state:p=null,key:f="default"}=n,b=v.useMemo(()=>{let w=Bm(d,l);return w==null?null:{location:{pathname:w,search:u,hash:m,state:p,key:f},navigationType:a}},[l,d,u,m,p,f,a]);return Sc(b!=null,` is not able to match the URL "${d}${u}${m}" because it does not start with the basename, so the won't render anything.`),b==null?null:v.createElement(El.Provider,{value:c},v.createElement(yS.Provider,{children:e,value:b}))}function Hpe({children:t,location:e}){return Tpe(TF(t),e)}function TF(t,e=[]){let n=[];return v.Children.forEach(t,(a,i)=>{if(!v.isValidElement(a))return;let s=[...e,i];if(a.type===v.Fragment){n.push.apply(n,TF(a.props.children,s));return}za(a.type===Cr,`[${typeof a.type=="string"?a.type:a.type.name}] is not a component. All component children of must be a or `),za(!a.props.index||!a.props.children,"An index route cannot have child routes.");let o={id:a.props.id||s.join("-"),caseSensitive:a.props.caseSensitive,element:a.props.element,Component:a.props.Component,index:a.props.index,path:a.props.path,middleware:a.props.middleware,loader:a.props.loader,action:a.props.action,hydrateFallbackElement:a.props.hydrateFallbackElement,HydrateFallback:a.props.HydrateFallback,errorElement:a.props.errorElement,ErrorBoundary:a.props.ErrorBoundary,hasErrorBoundary:a.props.hasErrorBoundary===!0||a.props.ErrorBoundary!=null||a.props.errorElement!=null,shouldRevalidate:a.props.shouldRevalidate,handle:a.props.handle,lazy:a.props.lazy};a.props.children&&(o.children=TF(a.props.children,s)),n.push(o)}),n}var uN="get",mN="application/x-www-form-urlencoded";function gT(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function qpe(t){return gT(t)&&t.tagName.toLowerCase()==="button"}function $pe(t){return gT(t)&&t.tagName.toLowerCase()==="form"}function Vpe(t){return gT(t)&&t.tagName.toLowerCase()==="input"}function Gpe(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Wpe(t,e){return t.button===0&&(!e||e==="_self")&&!Gpe(t)}function AF(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,n)=>{let a=t[n];return e.concat(Array.isArray(a)?a.map(i=>[n,i]):[[n,a]])},[]))}function Kpe(t,e){let n=AF(t);return e&&e.forEach((a,i)=>{n.has(i)||e.getAll(i).forEach(s=>{n.append(i,s)})}),n}var vk=null;function Ype(){if(vk===null)try{new FormData(document.createElement("form"),0),vk=!1}catch{vk=!0}return vk}var Xpe=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function nE(t){return t!=null&&!Xpe.has(t)?(Sc(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${mN}"`),null):t}function Qpe(t,e){let n,a,i,s,o;if($pe(t)){let l=t.getAttribute("action");a=l?Bm(l,e):null,n=t.getAttribute("method")||uN,i=nE(t.getAttribute("enctype"))||mN,s=new FormData(t)}else if(qpe(t)||Vpe(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a