From d65d440cfb97b85d319c458c59152aa388c48647 Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 10 Apr 2026 09:56:56 +0200 Subject: [PATCH] Fix external sidebar link icon missing when auth enabled (#878) The sidebar tag in Layout.tsx fetched custom external-link icons via a raw /api/v1/external-links/{id}/icon URL. That endpoint is protected by the shared camera-stream token (passed as ?token=xxx because tags cannot send Authorization headers), so the request came back 401 with the "Valid camera stream token required" message. The edit dialog already routed through api.getExternalLinkIconUrl(), which wraps the URL via withStreamToken(); the sidebar now does the same in both the open-in-new-tab and NavLink branches. --- CHANGELOG.md | 1 + frontend/src/components/Layout.tsx | 4 ++-- static/assets/{index-wUMtKBja.js => index-BLKXbeO0.js} | 2 +- static/index.html | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) rename static/assets/{index-wUMtKBja.js => index-BLKXbeO0.js} (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50614b9e2..c26fc13c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file. - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments. ### Fixed +- **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{"detail":"Valid camera stream token required..."}`. The sidebar `` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled. - **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode. - **SpoolBuddy Update Fails in Docker with "no user exists for uid 1001"** — The SpoolBuddy remote-update flow shelled out to `ssh-keygen` to create its update keypair on first use. Inside the Docker container the process runs under an arbitrary PUID (default 1000, also seen as 1001) that is not listed in `/etc/passwd`, so `ssh-keygen` aborted at the `getpwuid()` home-directory lookup and the update button reported `ssh-keygen failed. no user exists for uid 1001`. The keypair is now generated in-process via the `cryptography` library (already a dependency), which has no user-database lookup and produces the same OpenSSH-format files. Native installs are unaffected — they already worked because the running user was always in `/etc/passwd`. - **Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation. diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 09b019e58..9a2389525 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -561,7 +561,7 @@ export function Layout() { )} {link.custom_icon ? ( @@ -587,7 +587,7 @@ export function Layout() { )} {link.custom_icon ? ( diff --git a/static/assets/index-wUMtKBja.js b/static/assets/index-BLKXbeO0.js similarity index 95% rename from static/assets/index-wUMtKBja.js rename to static/assets/index-BLKXbeO0.js index f11511c0e..05d4a03be 100644 --- a/static/assets/index-wUMtKBja.js +++ b/static/assets/index-BLKXbeO0.js @@ -29,7 +29,7 @@ Valor ON: a string exata que significa "ON". Deixe vazio para detecção automá 乘数:Wh→kWh使用0.001,MWh→kWh使用1000。`,mqttStateHint:`JSON路径从JSON负载中提取值。原始值请留空。 ON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1)。`,restControl:"Control",restOnUrl:"Turn ON URL",restOffUrl:"Turn OFF URL",restOnBody:"ON Request Body",restOffBody:"OFF Request Body",restMethod:"HTTP Method",restHeaders:"Custom Headers (JSON)",restStatusUrl:"Status URL",restStatusPath:"State JSON Path",restStatusOnValue:"ON Value",restPowerUrl:"功率URL",restPowerPath:"Power JSON Path",restPowerMultiplier:"功率乘数",restEnergyUrl:"能耗URL",restEnergyPath:"Energy JSON Path",restEnergyMultiplier:"能耗乘数",restUrlRequired:"At least one URL (ON or OFF) is required for REST plugs",restHeadersHint:'e.g. {"Authorization": "Bearer your-token"}',restBodyHint:'e.g. ON, {"state": "on"}',restStatusHint:"URL to poll for current state",restPathHint:"e.g. state or data.power.status",restPowerUrlHint:"功率数据的独立URL(留空则使用状态URL)",restEnergyUrlHint:"能耗数据的独立URL(留空则使用状态URL)",restEnergyHint:"每个值可以使用独立的URL,或回退到状态URL。使用乘数进行单位转换(例如:0.001 将 Wh 转换为 kWh)。",testConnection:"Test Connection",connectionSuccess:"Connection successful",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 温度超过阈值",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:"(打印完成后)",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:"GitHub 备份",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:"当前耗材"},modal:{spoolDetected:"检测到耗材",assignToAms:"分配到 AMS",syncWeight:"同步重量",weightSynced:"已同步!",syncing:"同步中...",newTagDetected:"检测到新标签",addToInventory:"添加到库存",assignToAmsTitle:"分配到 AMS",selectSlot:"选择槽位",assign:"分配",assigning:"分配中...",assignSuccess:"已分配!",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:"运行时间",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:"创建耗材失败"},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:"发生了意外错误"}},Aue={en:{translation:Sue},de:{translation:_ue},fr:{translation:kue},ja:{translation:Nue},it:{translation:Cue},"pt-BR":{translation:Pue},"zh-CN":{translation:Tue}};oo.use(TX).use(eue).init({resources:Aue,fallbackLng:"en",supportedLngs:["en","de","fr","ja","it","pt-BR","zh-CN"],detection:{order:["localStorage","navigator","htmlTag"],lookupLocalStorage:"bambutrack_language",caches:["localStorage"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const G8=[{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:"简体中文"}];var W8="popstate";function Mue(t={}){function e(r,i){let{pathname:s,search:o,hash:l}=r.location;return C3("",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||"default")}function n(r,i){return typeof i=="string"?i:E0(i)}return Eue(e,n,null,t)}function ya(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function Kl(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function jue(){return Math.random().toString(36).substring(2,10)}function K8(t,e){return{usr:t.state,key:t.key,idx:e}}function C3(t,e,n=null,r){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?Yx(e):e,state:n,key:e&&e.key||r||jue()}}function E0({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 Yx(t){let e={};if(t){let n=t.indexOf("#");n>=0&&(e.hash=t.substring(n),t=t.substring(0,n));let r=t.indexOf("?");r>=0&&(e.search=t.substring(r),t=t.substring(0,r)),t&&(e.pathname=t)}return e}function Eue(t,e,n,r={}){let{window:i=document.defaultView,v5Compat:s=!1}=r,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 b=u(),g=b==null?null:b-d;d=b,c&&c({action:l,location:v.location,delta:g})}function p(b,g){l="PUSH";let _=C3(v.location,b,g);d=u()+1;let k=K8(_,d),P=v.createHref(_);try{o.pushState(k,"",P)}catch(N){if(N instanceof DOMException&&N.name==="DataCloneError")throw N;i.location.assign(P)}s&&c&&c({action:l,location:v.location,delta:1})}function f(b,g){l="REPLACE";let _=C3(v.location,b,g);d=u();let k=K8(_,d),P=v.createHref(_);o.replaceState(k,"",P),s&&c&&c({action:l,location:v.location,delta:0})}function y(b){return Due(b)}let v={get action(){return l},get location(){return t(i,o)},listen(b){if(c)throw new Error("A history only accepts one active listener");return i.addEventListener(W8,m),c=b,()=>{i.removeEventListener(W8,m),c=null}},createHref(b){return e(i,b)},createURL:y,encodeLocation(b){let g=y(b);return{pathname:g.pathname,search:g.search,hash:g.hash}},push:p,replace:f,go(b){return o.go(b)}};return v}function Due(t,e=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),ya(n,"No window.location.(origin|href) available to create URL");let r=typeof t=="string"?t:E0(t);return r=r.replace(/ $/,"%20"),!e&&r.startsWith("//")&&(r=n+r),new URL(r,n)}function AX(t,e,n="/"){return Fue(t,e,n,!1)}function Fue(t,e,n,r){let i=typeof e=="string"?Yx(e):e,s=qu(i.pathname||"/",n);if(s==null)return null;let o=MX(t);Rue(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(r)&&c)return;ya(u.relativePath.startsWith(r),`Absolute route path "${u.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(r.length)}let m=Fu([r,u.relativePath]),p=n.concat(u);o.children&&o.children.length>0&&(ya(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),MX(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:Hue(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of jX(o.path))s(o,l,!0,c)}),e}function jX(t){let e=t.split("/");if(e.length===0)return[];let[n,...r]=e,i=n.endsWith("?"),s=n.replace(/\?$/,"");if(r.length===0)return i?[s,""]:[s];let o=jX(r.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 Rue(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:que(e.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var Lue=/^:[\w-]+$/,Oue=3,Iue=2,zue=1,Uue=10,Bue=-2,Y8=t=>t==="*";function Hue(t,e){let n=t.split("/"),r=n.length;return n.some(Y8)&&(r+=Bue),e&&(r+=Iue),n.filter(i=>!Y8(i)).reduce((i,s)=>i+(Lue.test(s)?Oue:s===""?zue:Uue),r)}function que(t,e){return t.length===e.length&&t.slice(0,-1).every((r,i)=>r===e[i])?t[t.length-1]-e[e.length-1]:0}function $ue(t,e,n=!1){let{routesMeta:r}=t,i={},s="/",o=[];for(let l=0;l{if(u==="*"){let y=l[p]||"";o=s.slice(0,s.length-y.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 Vue(t,e=!1,n=!0){Kl(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 r=[],i="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(r.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(r.push({paramName:"*"}),i+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?i+="\\/*$":t!==""&&t!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,e?void 0:"i"),r]}function Gue(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return Kl(!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 qu(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let n=e.endsWith("/")?e.length-1:e.length,r=t.charAt(n);return r&&r!=="/"?null:t.slice(n)||"/"}var Wue=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function Kue(t,e="/"){let{pathname:n,search:r="",hash:i=""}=typeof t=="string"?Yx(t):t,s;return n?(n=n.replace(/\/\/+/g,"/"),n.startsWith("/")?s=X8(n.substring(1),"/"):s=X8(n,e)):s=e,{pathname:s,search:Que(r),hash:Zue(i)}}function X8(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 oj(t,e,n,r){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(r)}]. 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 Yue(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function WL(t){let e=Yue(t);return e.map((n,r)=>r===e.length-1?n.pathname:n.pathnameBase)}function KL(t,e,n,r=!1){let i;typeof t=="string"?i=Yx(t):(i={...t},ya(!i.pathname||!i.pathname.includes("?"),oj("?","pathname","search",i)),ya(!i.pathname||!i.pathname.includes("#"),oj("#","pathname","hash",i)),ya(!i.search||!i.search.includes("#"),oj("#","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(!r&&o.startsWith("..")){let p=o.split("/");for(;p[0]==="..";)p.shift(),m-=1;i.pathname=p.join("/")}l=m>=0?e[m]:"/"}let c=Kue(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var Fu=t=>t.join("/").replace(/\/\/+/g,"/"),Xue=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),Que=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,Zue=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,Jue=class{constructor(t,e,n,r=!1){this.status=t,this.statusText=e||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function eme(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function tme(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var EX=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function DX(t,e){let n=t;if(typeof n!="string"||!Wue.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(EX)try{let s=new URL(window.location.href),o=n.startsWith("//")?new URL(s.protocol+n):new URL(n),l=qu(o.pathname,e);o.origin===s.origin&&l!=null?n=l+o.search+o.hash:i=!0}catch{Kl(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var FX=["POST","PUT","PATCH","DELETE"];new Set(FX);var nme=["GET",...FX];new Set(nme);var Xx=w.createContext(null);Xx.displayName="DataRouter";var vP=w.createContext(null);vP.displayName="DataRouterState";var rme=w.createContext(!1),RX=w.createContext({isTransitioning:!1});RX.displayName="ViewTransition";var ame=w.createContext(new Map);ame.displayName="Fetchers";var ime=w.createContext(null);ime.displayName="Await";var dl=w.createContext(null);dl.displayName="Navigation";var Nw=w.createContext(null);Nw.displayName="Location";var ac=w.createContext({outlet:null,matches:[],isDataRoute:!1});ac.displayName="Route";var YL=w.createContext(null);YL.displayName="RouteError";var LX="REACT_ROUTER_ERROR",sme="REDIRECT",ome="ROUTE_ERROR_RESPONSE";function lme(t){if(t.startsWith(`${LX}:${sme}:{`))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 cme(t){if(t.startsWith(`${LX}:${ome}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new Jue(e.status,e.statusText,e.data)}catch{}}function dme(t,{relative:e}={}){ya(Qx(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=w.useContext(dl),{hash:i,pathname:s,search:o}=Pw(t,{relative:e}),l=s;return n!=="/"&&(l=s==="/"?n:Fu([n,s])),r.createHref({pathname:l,search:o,hash:i})}function Qx(){return w.useContext(Nw)!=null}function Ic(){return ya(Qx(),"useLocation() may be used only in the context of a component."),w.useContext(Nw).location}var OX="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function IX(t){w.useContext(dl).static||w.useLayoutEffect(t)}function Eo(){let{isDataRoute:t}=w.useContext(ac);return t?kme():ume()}function ume(){ya(Qx(),"useNavigate() may be used only in the context of a component.");let t=w.useContext(Xx),{basename:e,navigator:n}=w.useContext(dl),{matches:r}=w.useContext(ac),{pathname:i}=Ic(),s=JSON.stringify(WL(r)),o=w.useRef(!1);return IX(()=>{o.current=!0}),w.useCallback((c,d={})=>{if(Kl(o.current,OX),!o.current)return;if(typeof c=="number"){n.go(c);return}let u=KL(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:Fu([e,u.pathname])),(d.replace?n.replace:n.push)(u,d.state,d)},[e,n,s,i,t])}var zX=w.createContext(null);function Zx(){return w.useContext(zX)}function mme(t){let e=w.useContext(ac).outlet;return w.useMemo(()=>e&&w.createElement(zX.Provider,{value:t},e),[e,t])}function Cw(){let{matches:t}=w.useContext(ac),e=t[t.length-1];return e?e.params:{}}function Pw(t,{relative:e}={}){let{matches:n}=w.useContext(ac),{pathname:r}=Ic(),i=JSON.stringify(WL(n));return w.useMemo(()=>KL(t,JSON.parse(i),r,e==="path"),[t,i,r,e])}function hme(t,e){return UX(t,e)}function UX(t,e,n,r,i){ya(Qx(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=w.useContext(dl),{matches:o}=w.useContext(ac),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||"";HX(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=Ic(),f;if(e){let _=typeof e=="string"?Yx(e):e;ya(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 y=f.pathname||"/",v=y;if(u!=="/"){let _=u.replace(/^\//,"").split("/");v="/"+y.replace(/^\//,"").split("/").slice(_.length).join("/")}let b=AX(t,{pathname:v});Kl(m||b!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),Kl(b==null||b[b.length-1].route.element!==void 0||b[b.length-1].route.Component!==void 0||b[b.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 g=xme(b&&b.map(_=>Object.assign({},_,{params:Object.assign({},c,_.params),pathname:Fu([u,s.encodeLocation?s.encodeLocation(_.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathname]),pathnameBase:_.pathnameBase==="/"?u:Fu([u,s.encodeLocation?s.encodeLocation(_.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathnameBase])})),o,n,r,i);return e&&g?w.createElement(Nw.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},g):g}function pme(){let t=_me(),e=eme(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),n=t instanceof Error?t.stack:null,r="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:r},s={padding:"2px 4px",backgroundColor:r},o=null;return console.error("Error handled by React Router default ErrorBoundary:",t),o=w.createElement(w.Fragment,null,w.createElement("p",null,"💿 Hey developer 👋"),w.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",w.createElement("code",{style:s},"ErrorBoundary")," or"," ",w.createElement("code",{style:s},"errorElement")," prop on your route.")),w.createElement(w.Fragment,null,w.createElement("h2",null,"Unexpected Application Error!"),w.createElement("h3",{style:{fontStyle:"italic"}},e),n?w.createElement("pre",{style:i},n):null,o)}var fme=w.createElement(pme,null),BX=class extends w.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=cme(t.digest);n&&(t=n)}let e=t!==void 0?w.createElement(ac.Provider,{value:this.props.routeContext},w.createElement(YL.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?w.createElement(gme,{error:t},e):e}};BX.contextType=rme;var lj=new WeakMap;function gme({children:t,error:e}){let{basename:n}=w.useContext(dl);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let r=lme(e.digest);if(r){let i=lj.get(e);if(i)throw i;let s=DX(r.location,n);if(EX&&!lj.get(e))if(s.isExternal||r.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:r.replace}));throw lj.set(e,o),o}return w.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function bme({routeContext:t,match:e,children:n}){let r=w.useContext(Xx);return r&&r.static&&r.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=e.route.id),w.createElement(ac.Provider,{value:t},n)}function xme(t,e=[],n=null,r=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);ya(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&&r?(u,m)=>{r(u,{location:n.location,params:n.matches?.[0]?.params??{},unstable_pattern:tme(n.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,y=!1,v=null,b=null;n&&(f=o&&m.route.id?o[m.route.id]:void 0,v=m.route.errorElement||fme,l&&(c<0&&p===0?(HX("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),y=!0,b=null):c===p&&(y=!0,b=m.route.hydrateFallbackElement||null)));let g=e.concat(s.slice(0,p+1)),_=()=>{let k;return f?k=v:y?k=b:m.route.Component?k=w.createElement(m.route.Component,null):m.route.element?k=m.route.element:k=u,w.createElement(bme,{match:m,routeContext:{outlet:u,matches:g,isDataRoute:n!=null},children:k})};return n&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?w.createElement(BX,{location:n.location,revalidation:n.revalidation,component:v,error:f,children:_(),routeContext:{outlet:null,matches:g,isDataRoute:!0},onError:d}):_()},null)}function XL(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function yme(t){let e=w.useContext(Xx);return ya(e,XL(t)),e}function vme(t){let e=w.useContext(vP);return ya(e,XL(t)),e}function wme(t){let e=w.useContext(ac);return ya(e,XL(t)),e}function QL(t){let e=wme(t),n=e.matches[e.matches.length-1];return ya(n.route.id,`${t} can only be used on routes that contain a unique "id"`),n.route.id}function Sme(){return QL("useRouteId")}function _me(){let t=w.useContext(YL),e=vme("useRouteError"),n=QL("useRouteError");return t!==void 0?t:e.errors?.[n]}function kme(){let{router:t}=yme("useNavigate"),e=QL("useNavigate"),n=w.useRef(!1);return IX(()=>{n.current=!0}),w.useCallback(async(i,s={})=>{Kl(n.current,OX),n.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var Q8={};function HX(t,e,n){!e&&!Q8[t]&&(Q8[t]=!0,Kl(!1,n))}w.memo(Nme);function Nme({routes:t,future:e,state:n,onError:r}){return UX(t,void 0,n,r,e)}function gx({to:t,replace:e,state:n,relative:r}){ya(Qx()," may be used only in the context of a component.");let{static:i}=w.useContext(dl);Kl(!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}=w.useContext(ac),{pathname:o}=Ic(),l=Eo(),c=KL(t,WL(s),o,r==="path"),d=JSON.stringify(c);return w.useEffect(()=>{l(JSON.parse(d),{replace:e,state:n,relative:r})},[l,d,r,e,n]),null}function qX(t){return mme(t.context)}function Mr(t){ya(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Cme({basename:t="/",children:e=null,location:n,navigationType:r="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){ya(!Qx(),"You cannot render a inside another . You should never have more than one in your app.");let l=t.replace(/^\/*/,"/"),c=w.useMemo(()=>({basename:l,navigator:i,static:s,unstable_useTransitions:o,future:{}}),[l,i,s,o]);typeof n=="string"&&(n=Yx(n));let{pathname:d="/",search:u="",hash:m="",state:p=null,key:f="default"}=n,y=w.useMemo(()=>{let v=qu(d,l);return v==null?null:{location:{pathname:v,search:u,hash:m,state:p,key:f},navigationType:r}},[l,d,u,m,p,f,r]);return Kl(y!=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.`),y==null?null:w.createElement(dl.Provider,{value:c},w.createElement(Nw.Provider,{children:e,value:y}))}function Pme({children:t,location:e}){return hme(P3(t),e)}function P3(t,e=[]){let n=[];return w.Children.forEach(t,(r,i)=>{if(!w.isValidElement(r))return;let s=[...e,i];if(r.type===w.Fragment){n.push.apply(n,P3(r.props.children,s));return}ya(r.type===Mr,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),ya(!r.props.index||!r.props.children,"An index route cannot have child routes.");let o={id:r.props.id||s.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(o.children=P3(r.props.children,s)),n.push(o)}),n}var bk="get",xk="application/x-www-form-urlencoded";function wP(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Tme(t){return wP(t)&&t.tagName.toLowerCase()==="button"}function Ame(t){return wP(t)&&t.tagName.toLowerCase()==="form"}function Mme(t){return wP(t)&&t.tagName.toLowerCase()==="input"}function jme(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Eme(t,e){return t.button===0&&(!e||e==="_self")&&!jme(t)}function T3(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,n)=>{let r=t[n];return e.concat(Array.isArray(r)?r.map(i=>[n,i]):[[n,r]])},[]))}function Dme(t,e){let n=T3(t);return e&&e.forEach((r,i)=>{n.has(i)||e.getAll(i).forEach(s=>{n.append(i,s)})}),n}var C_=null;function Fme(){if(C_===null)try{new FormData(document.createElement("form"),0),C_=!1}catch{C_=!0}return C_}var Rme=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function cj(t){return t!=null&&!Rme.has(t)?(Kl(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${xk}"`),null):t}function Lme(t,e){let n,r,i,s,o;if(Ame(t)){let l=t.getAttribute("action");r=l?qu(l,e):null,n=t.getAttribute("method")||bk,i=cj(t.getAttribute("enctype"))||xk,s=new FormData(t)}else if(Tme(t)||Mme(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a