From e9b6fefe95040dfc6bfb6443e198fcd9ba529401 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 9 Jun 2026 09:25:12 +0200 Subject: [PATCH] fix(auth): redirect to /login when JWT expires mid-session (#1698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the JWT expired on an open tab, the next API request hit a 401 with "Token has expired"; client.ts cleared the token from storage but AuthContext.user stayed populated from the original mount. ProtectedRoute only redirects when user === null, so the protected tree kept rendering and every subsequent request silently failed with no Authorization header — the UI looked like every list was empty until a manual refresh remounted AuthProvider. The 3 other setAuthToken(null) sites live inside AuthContext itself and already pair with setUser(null), so only the client.ts cross-module site needed a React-tree signal. - client.ts: after setAuthToken(null) on a token-invalidating 401, window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on `typeof window !== 'undefined'` for SSR / test safety. Generic "Authentication required" 401s still don't clear the token or fire the event — treated as transient timing issues per the pre-existing comment at client.ts:155. - AuthContext.tsx: mount useEffect adds a window listener that calls setUser(null) under the mountedRef guard; cleanup removes the listener so unmount → remount doesn't double-bind. Mirrors the patch the reporter shipped on their fork (deec96d1). --- CHANGELOG.md | 2 + frontend/src/__tests__/api/client.test.ts | 49 ++++++++++++++ .../__tests__/contexts/AuthContext.test.tsx | 67 ++++++++++++++++++- frontend/src/api/client.ts | 7 ++ frontend/src/contexts/AuthContext.tsx | 12 ++++ .../{index-BBXh9elm.js => index-fRNJA0id.js} | 2 +- static/index.html | 2 +- 7 files changed, 136 insertions(+), 5 deletions(-) rename static/assets/{index-BBXh9elm.js => index-fRNJA0id.js} (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9470a2e6d..76fd591fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to Bambuddy will be documented in this file. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). ### Fixed +- **Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by @TCL987, fix patched in reporter's fork)** — Reporter on X1C, Docker install, left a Bambuddy tab open past the 24 h JWT lifetime. After expiry: navigation between pages still worked, but every API request silently failed, leaving the UI looking like every list was empty. A manual refresh was needed to land on `/login`. **Root cause: `AuthContext.user` stays stale after the JWT clears.** When a 401 with a token-invalidating message (`Token has expired`, `Could not validate credentials`, `User not found or inactive`, `Invalid API key`, `API key has expired`) lands in `frontend/src/api/client.ts:154-167`, the handler calls `setAuthToken(null)` to drop the token from sessionStorage / localStorage — but `AuthContext.user` is a React state value that was populated once at mount via `checkAuthStatus()` → `/auth/me`, and `setAuthToken(null)` doesn't reach into AuthContext's React tree. `ProtectedRoute` (`App.tsx:101`) only redirects when `user === null`, so the protected tree keeps rendering, every subsequent request goes out with no Authorization header, the backend 401s, and the UI shows nothing. A page refresh remounts `AuthProvider`, `checkAuthStatus()` finds no token, `setUser(null)` fires, the redirect runs — which is what the reporter ended up doing every 24 h. The 3 other `setAuthToken(null)` call sites all live inside `AuthContext` itself and pair with `setUser(null)` directly, so no cross-module signal was needed for them; the `client.ts:165` site was the only one missing the React-tree notification. **Fix (mirrors the reporter's fork patch deec96d1):** after `setAuthToken(null)` in `client.ts`, dispatch a `window.dispatchEvent(new CustomEvent('auth:expired'))` (guarded on `typeof window !== 'undefined'` for SSR / test safety). `AuthContext`'s mount `useEffect` adds a `window.addEventListener('auth:expired', handleAuthExpired)` listener whose handler calls `setUser(null)` after a `mountedRef.current` guard, and removes the listener in the effect's cleanup so unmount → remount doesn't double-bind. `ProtectedRoute` then sees `user === null` on the next render and runs `` immediately, no manual refresh needed. **What this intentionally does NOT change**: generic `401 Authentication required` responses (without a token-invalidating message) still don't clear the token or fire the event — they're treated as transient timing issues, exactly as `client.ts:155`'s pre-existing comment documents. So a one-off 401 from a race during login won't redirect a working session. Listener cleanup means tests / dev hot-reload don't accumulate handlers. **Tests:** 4 new vitest cases — `client.test.ts` gains "dispatches 'auth:expired' event on 401 with invalid token message" and "does not dispatch 'auth:expired' on 401 with generic auth error" (both use `vi.fn()` listeners on `window` to assert the event fires/doesn't fire). `AuthContext.test.tsx` gains a new `auth:expired event (#1698)` describe block — "clears user when an auth:expired event is dispatched" simulates the login → expiry → event → user-null flow end-to-end via `setAuthToken('valid-token')` (the canonical setter; writing to sessionStorage post-import wouldn't propagate to the module-level `authToken` variable initialised at import time), and "does not crash when the event fires after unmount" pins the `mountedRef` guard so the listener can't trigger a state-update-after-unmount warning. Full frontend vitest suite: 2087 / 2087 green. ESLint clean on touched files. Frontend build clean. **Credit to @TCL987** for diagnosing this and shipping the working fix on their fork before opening the issue. + - **Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by @volodymyr-doba)** — Reporter on P1S printed a single lid (~190 g grey PETG) from `gridfinity-storage-box-5x4x6.gcode.3mf` (a multi-plate file with 5×box + 5×lid plates) and the spool's Usage History recorded 242 g of grey + 31 g of black — the **whole file's** filament total, not the dispatched plate. The print took 5 h 47 m which matches the lid alone, and the queue card correctly previewed 190 g, but the spool got debited for everything. **Root cause: usage tracking parsed the 3MF without a plate filter.** `extract_filament_usage_from_3mf(file_path, plate_id)` in `backend/app/utils/threemf_tools.py` already supports filtering and the queue's pre-flight capacity check at `api/routes/print_queue.py:254/:286` passes `item.plate_id`, but the two completion-time recorders did not: `_track_from_3mf` in `services/usage_tracker.py:907` (internal Filament Inventory) and `store_print_data` in `services/spoolman_tracking.py:223` (Spoolman mode) both called the extractor with no plate_id and summed every plate. Per `feedback_inventory_modes_parity` both modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (`api.reprintArchive` / `api.printLibraryFile` with `plate_id: selectedPlate` in `PrintModal/index.tsx:739/750`) hits the same bug because it never goes through the queue — caught before merge by tracing the frontend dispatch surface end-to-end. **Fix — two complementary captures:** (1) `PrintSession` gains a `plate_id: int | None` field; `on_print_start` queries `PrintQueueItem` for the printer's currently-printing row and records `queue_item.plate_id` onto the session — covers the queue path. (2) `register_expected_print` in `main.py` accepts a new `plate_id` parameter and stores it in a parallel `_print_plate_ids: dict[int, int]` dict (mirror of `_print_ams_mappings`); `background_dispatch.py`'s 2 register sites and `print_scheduler.py`'s 1 register site now pass plate_id (the dispatch already resolved it via `_resolve_plate_id`; reordering the resolve to run before register is a no-op since the resolver is pure). At expected-print promotion, `main.py` injects `_print_plate_ids[archive_id]` into `_active_sessions[printer_id].plate_id` (only when the session has no plate_id yet — queue captures win), mirroring the existing `ams_mapping` injection pattern. The dict drains on `on_print_complete` and on TTL eviction of the matching `_expected_prints` entry — same lifecycle as `_print_ams_mappings`. (3) `_track_from_3mf` accepts a new `plate_id` kwarg, threads it from `session.plate_id`, and passes it to `extract_filament_usage_from_3mf`. (4) `store_print_data` accepts a `plate_id` kwarg; the 3 call sites in `main.py` pass `_get_start_plate_id(archive_id)` (new helper, parallel to `_get_start_ams_mapping`); within `store_print_data` the caller value wins, falling back to `queue_item.plate_id` for the queue path. **The PrintArchive's `filament_used_grams` stays file-level summed by design** (#1593's contract — the archive describes the file, not the run); only the per-run usage attribution becomes plate-aware. **What this intentionally does NOT touch:** for direct Print of a single-plate file, `_resolve_plate_id` returns 1 → registered as `plate_id=1`, which extracts plate 1 = the whole file — identical to the prior no-filter behaviour. The change is observable only for multi-plate 3MFs where a specific non-first plate was dispatched. **Tests:** 9 new across `test_usage_tracker.py` + `test_spoolman_tracking.py` + `test_print_start_expected_promotion.py` — plate_id propagation through `_track_from_3mf`; absence leaves it `None`; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract; `register_expected_print` stores `plate_id` in `_print_plate_ids`; `_get_start_plate_id` reads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existing `test_prefers_explicit_ams_mapping_over_queue_mapping` updated for the new unconditional queue lookup (was conditional, now always queries to capture plate_id). Full 5830-test backend suite green. Ruff clean across the entire backend, not just touched files. - **AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by @kleinwareio)** — On a 3-AMS P1S the reporter's screenshot showed AMS-C slots labelled "Empty" even though spools were physically loaded; OrcaSlicer's Device view showed the same slots as loaded. **Root cause:** the compact label below the AMS slot circle in PrintersPage rendered `tray.tray_type || t('ams.slotEmpty')`, falling back to "Empty" whenever the printer firmware hadn't been told which material is in the slot. The codebase already had a `getEmptySlotKind` helper that distinguishes `'physical'` (firmware confirmed empty via state 9/10) from `'reset'` (tray_type absent but firmware hasn't confirmed empty — i.e. spool loaded, just unassigned). The hover-card / circle border already used that distinction (line 814+ comment); the compact label did not. **Fix:** label now branches on `emptyKind` — `'physical'` keeps "Empty" (the firmware-confirmed empty case), `'reset'` shows "?" (matching the slicer's own convention for "loaded but unknown material"). External / VT tray label is unchanged (external trays have no "configured/unconfigured" distinction — they're either loaded or not). The SpoolBuddy kiosk's `AmsUnitCard` was carrying the same bug and got the same fix (mirror of `getEmptySlotKind`, "?" vs "Empty" label, tooltip "Spool loaded — slot not configured"). **i18n:** new `ams.slotUnconfigured: '?'` key added to all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — value is universal so it's identical in every locale. The existing `ams.emptySlotReset = 'No filament assigned'` tooltip surface in FilamentHoverCard already covers the "what does this mean" question on hover, so no new tooltip key needed for the main card. **Tests:** AmsUnitCard vitest gains `shows "?" for loaded-but-unconfigured slot (#1694)` pinning both branches in one render (one slot with `state: 9` → "Empty", one with no state → "?"). Existing AMS tests stay green (9/9 SpoolBuddy AmsUnitCard; AMS load/unload page tests untouched and green); i18n parity green (5100 leaves × 11 locales); frontend build clean; ESLint clean. diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index 9c79f84c5..97aecbf7b 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -196,6 +196,55 @@ describe('API Client Auth Header', () => { // Token should NOT be cleared for generic auth errors (might be timing issue) expect(getAuthToken()).toBe('valid-token'); }); + + it("dispatches 'auth:expired' event on 401 with invalid token message (#1698)", async () => { + server.use( + http.get('/api/v1/settings/spoolman', () => { + return HttpResponse.json( + { detail: 'Token has expired' }, + { status: 401 } + ); + }) + ); + + setAuthToken('expired-token'); + const listener = vi.fn(); + window.addEventListener('auth:expired', listener); + + try { + await api.getSpoolmanSettings(); + } catch { + // Expected to throw + } + + expect(listener).toHaveBeenCalledTimes(1); + window.removeEventListener('auth:expired', listener); + }); + + it("does not dispatch 'auth:expired' on 401 with generic auth error (#1698)", async () => { + server.use( + http.get('/api/v1/settings/spoolman', () => { + return HttpResponse.json( + { detail: 'Authentication required' }, + { status: 401 } + ); + }) + ); + + setAuthToken('valid-token'); + const listener = vi.fn(); + window.addEventListener('auth:expired', listener); + + try { + await api.getSpoolmanSettings(); + } catch { + // Expected to throw + } + + // Generic 401s might be timing issues, not real expiries — must NOT redirect. + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener('auth:expired', listener); + }); }); describe('Slicer download URLs', () => { diff --git a/frontend/src/__tests__/contexts/AuthContext.test.tsx b/frontend/src/__tests__/contexts/AuthContext.test.tsx index 7bc57ff6e..c214727f4 100644 --- a/frontend/src/__tests__/contexts/AuthContext.test.tsx +++ b/frontend/src/__tests__/contexts/AuthContext.test.tsx @@ -2,8 +2,8 @@ * Tests for the AuthContext permission helpers. */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter } from 'react-router-dom'; import { http, HttpResponse } from 'msw'; @@ -11,7 +11,7 @@ import { server } from '../mocks/server'; import { AuthProvider, useAuth } from '../../contexts/AuthContext'; import { ThemeProvider } from '../../contexts/ThemeContext'; import { ToastProvider } from '../../contexts/ToastContext'; -import type { Permission } from '../../api/client'; +import { setAuthToken, type Permission } from '../../api/client'; function createWrapper() { const queryClient = new QueryClient({ @@ -264,4 +264,65 @@ describe('AuthContext', () => { ).toBe(true); }); }); + + describe('auth:expired event (#1698)', () => { + beforeEach(() => { + // authToken is a module-level variable initialised once at import time; + // writing to sessionStorage after import doesn't propagate. Use the + // canonical setter so checkAuthStatus() finds the token and loads /me. + setAuthToken('valid-token'); + server.use( + http.get('/api/v1/auth/status', () => { + return HttpResponse.json({ + auth_enabled: true, + requires_setup: false, + }); + }) + ); + }); + + afterEach(() => { + setAuthToken(null); + }); + + it('clears user when an auth:expired event is dispatched', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + // Wait for the initial user load from /auth/me. + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + + // Simulate client.ts dispatching the event after a 401 + token clear. + act(() => { + window.dispatchEvent(new CustomEvent('auth:expired')); + }); + + // User is cleared synchronously — ProtectedRoute (App.tsx:101) sees + // user === null and redirects to /login on the next render. + await waitFor(() => { + expect(result.current.user).toBeNull(); + }); + }); + + it('does not crash when the event fires after unmount', async () => { + const { result, unmount } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + + unmount(); + + // mountedRef guards setUser; dispatching after unmount must be a no-op, + // not a React state-update-after-unmount warning. + expect(() => { + window.dispatchEvent(new CustomEvent('auth:expired')); + }).not.toThrow(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d4983c452..056038291 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -163,6 +163,13 @@ async function request( ]; if (invalidTokenMessages.some(m => message.includes(m))) { setAuthToken(null); + // Notify AuthContext so the protected route guard re-evaluates and + // redirects to /login on the same tab — without this, AuthContext.user + // stays cached and the tab silently fails every request until a manual + // refresh remounts AuthProvider (#1698, reported by @TCL987). + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('auth:expired')); + } } } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 2937f5144..0d3d489b3 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -94,8 +94,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { mountedRef.current = true; // Check auth status on mount checkAuthStatus(); + + // Listen for token-expiry events from the API client. setAuthToken(null) + // in client.ts only clears storage; without this listener, `user` stays + // populated and ProtectedRoute keeps rendering the protected tree until a + // manual refresh — every request silently fails in the meantime (#1698). + const handleAuthExpired = () => { + if (!mountedRef.current) return; + setUser(null); + }; + window.addEventListener('auth:expired', handleAuthExpired); + return () => { mountedRef.current = false; + window.removeEventListener('auth:expired', handleAuthExpired); }; }, []); diff --git a/static/assets/index-BBXh9elm.js b/static/assets/index-fRNJA0id.js similarity index 95% rename from static/assets/index-BBXh9elm.js rename to static/assets/index-fRNJA0id.js index 4840e971a..5d8594b3d 100644 --- a/static/assets/index-BBXh9elm.js +++ b/static/assets/index-fRNJA0id.js @@ -41,7 +41,7 @@ mW→W için 0.001, kW→W için 1000 çarpanı kullanın.`,mqttEnergyHint:`JSON Wh→kWh için 0.001, MWh→kWh için 1000 çarpanı kullanın.`,mqttStateHint:`JSON yolu JSON yükten değer çıkarır. Ham değerler için boş bırakın. ON değeri: "ON" anlamına gelen tam string. Otomatik algılama için boş bırakın (ON, true, 1).`,restControl:"Kontrol",restOnUrl:"AÇ URL'si",restOffUrl:"KAPAT URL'si",restOnBody:"AÇ İstek Gövdesi",restOffBody:"KAPAT İstek Gövdesi",restMethod:"HTTP Yöntemi",restHeaders:"Özel Başlıklar (JSON)",restStatusUrl:"Durum URL'si",restStatusPath:"Durum JSON Yolu",restStatusOnValue:"ON Değeri",restPowerUrl:"Güç URL'si",restPowerPath:"Güç JSON Yolu",restPowerMultiplier:"Güç Çarpanı",restEnergyUrl:"Enerji URL'si",restEnergyPath:"Enerji JSON Yolu",restEnergyMultiplier:"Enerji Çarpanı",restUrlRequired:"REST prizleri için en az bir URL (ON veya OFF) gerekli",restHeadersHint:'örn. {"Authorization": "Bearer your-token"}',restBodyHint:'örn. ON, {"state": "on"}',restStatusHint:"Mevcut durumu sorgulamak için URL",restPathHint:"örn. state veya data.power.status",restPowerUrlHint:"Güç verisi için ayrı URL (boşsa Durum URL'sini kullanır)",restEnergyUrlHint:"Enerji verisi için ayrı URL (boşsa Durum URL'sini kullanır)",restEnergyHint:"Her değer kendi URL'sini kullanabilir veya Durum URL'sine geri dönebilir. Birim dönüşümü için çarpanları kullanın (örn. Wh'yi kWh'ye dönüştürmek için 0.001).",testConnection:"Bağlantıyı Test Et",connectionSuccess:"Bağlantı başarılı",noSwitchesInSwitchbar:"Anahtar çubuğunda anahtar yok",enableSwitchbarHint:`Ayarlar > Akıllı Prizler'de "Anahtar Çubuğunda Göster"i etkinleştirin`},notifications:{providerTypes:{callmebot:"CallMeBot/WhatsApp",ntfy:"ntfy",pushover:"Pushover",telegram:"Telegram",email:"E-posta",discord:"Discord",webhook:"Webhook",homeassistant:"Home Assistant"},providerDescriptions:{email:"SMTP e-posta bildirimleri",telegram:"Telegram botu üzerinden bildirimler",discord:"Webhook üzerinden Discord kanalına gönder",ntfy:"Ücretsiz, kendi barındırılabilir push bildirimleri",pushover:"Basit, güvenilir push bildirimleri",callmebot:"CallMeBot üzerinden ücretsiz WhatsApp bildirimleri",webhook:"Herhangi bir URL'ye genel HTTP POST",homeassistant:"Home Assistant gösterge panelinde kalıcı bildirimler"},lastSuccess:"Son: {{date}}",error:"Hata",printer:"Yazıcı:",allPrinters:"Tüm yazıcılar",sendTestNotification:"Test Bildirimi Gönder",eventSettings:"Olay Ayarları",enabled:"Etkin",sendFromProvider:"Bu sağlayıcıdan bildirim gönder",printEvents:"Baskı Olayları",printerStatus:"Yazıcı Durumu",amsAlarms:"AMS Alarmları",amsHtAlarms:"AMS-HT Alarmları",printQueue:"Baskı Kuyruğu",start:"Başlangıç",plateCheck:"Plaka Kontrolü",complete:"Tamamlandı",failed:"Başarısız",stopped:"Durduruldu",progress:"İlerleme",offline:"Çevrimdışı",lowFilament:"Az Filament",maintenance:"Bakım",amsHumidity:"AMS Nemi",amsTemp:"AMS Sıcaklığı",amsHtHumidity:"AMS-HT Nemi",amsHtTemp:"AMS-HT Sıcaklığı",bedCooled:"Tabla Soğudu",firstLayer:"İlk Katman",quiet:"Sessiz",digest:"Özet {{time}}",printStarted:"Baskı Başladı",plateNotEmpty:"Plaka Boş Değil",plateNotEmptyDescription:"Baskıdan önce nesneler algılandı",printCompleted:"Baskı Tamamlandı",bedCooledLabel:"Tabla Soğudu",bedCooledDescription:"Baskıdan sonra tabla eşiğin altına soğudu",firstLayerCompleteLabel:"İlk Katman Tamamlandı",firstLayerCompleteDescription:"İlk katman bittiğinde anlık görüntüyle bildir",missingSpoolAssignmentLabel:"Eksik Makara Ataması",missingSpoolAssignmentDescription:"Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir",printFailed:"Baskı Başarısız",printStopped:"Baskı Durduruldu",progressMilestones:"İlerleme Kilometre Taşları",progressMilestonesDescription:"%25, %50, %75'te bildir",printerOffline:"Yazıcı Çevrimdışı",printerError:"Yazıcı Hatası",lowFilamentLabel:"Az Filament",maintenanceDue:"Bakım Zamanı",maintenanceDueDescription:"Bakım gerektiğinde bildir",amsHumidityHigh:"AMS Nemi Yüksek",amsHumidityHighDescription:"Normal AMS nemi eşiği aşıyor",amsTemperatureHigh:"AMS Sıcaklığı Yüksek",amsTemperatureHighDescription:"Normal AMS sıcaklığı eşiği aşıyor",amsHtHumidityHigh:"AMS-HT Nemi Yüksek",amsHtHumidityHighDescription:"AMS-HT nemi eşiği aşıyor",amsHtTemperatureHigh:"AMS-HT Sıcaklığı Yüksek",amsHtTemperatureHighDescription:"AMS-HT sıcaklığı eşiği aşıyor",inventoryAlerts:"Envanter Uyarıları",stockReorderAlert:"Yeniden Sipariş Uyarısı",stockReorderAlertDescription:"SKU yeniden sipariş noktasına ulaştı",stockBreakAlert:"Stok Tükenme Uyarısı",stockBreakAlertDescription:"Yenileme gelmeden stok tükenecek",jobAdded:"İş Eklendi",jobAddedDescription:"İş kuyruğa eklendi",jobAssigned:"İş Atandı",jobAssignedDescription:"Model tabanlı iş yazıcıya atandı",jobStarted:"İş Başladı",jobStartedDescription:"Kuyruk işi yazdırmaya başladı",jobWaiting:"İş Bekliyor",jobWaitingDescription:"İş filament veya yazıcı için bekliyor",jobSkipped:"İş Atlandı",jobSkippedDescription:"İş atlandı (önceki başarısız oldu)",jobFailed:"İş Başarısız",jobFailedDescription:"İş başlatılamadı",queueComplete:"Kuyruk Tamamlandı",queueCompleteDescription:"Tüm kuyruk işleri bitti",quietHours:"Sessiz Saatler",noNotificationsDuring:"Bu saatler arasında bildirim yok",editProviderToChangeQuietHours:"Sessiz saatleri değiştirmek için sağlayıcıyı düzenleyin",dailyDigest:"Günlük Özet",batchNotifications:"Bildirimleri tek bir günlük özette topla",sendAt:"{{time}}'de gönder",editProviderToChangeDigestTime:"Özet saatini değiştirmek için sağlayıcıyı düzenleyin",edit:"Düzenle",deleteProvider:"Bildirim Sağlayıcısını Sil",deleteConfirm:'"{{name}}" silmek istediğinizden emin misiniz? Bu geri alınamaz.',delete:"Sil",addTitle:"Bildirim Sağlayıcısı Ekle",editTitle:"Bildirim Sağlayıcısını Düzenle",nameLabel:"Ad *",namePlaceholder:"Bildirimlerim",providerTypeLabel:"Sağlayıcı Türü *",configuration:"Yapılandırma",testConfiguration:"Yapılandırmayı Test Et",printerFilter:"Yazıcı Filtresi",onlyFromPrinter:"Yalnızca bu yazıcının olayları için bildirim gönder",quietHoursDnd:"Sessiz Saatler (Rahatsız Etme)",quietStart:"Başlangıç",quietEnd:"Bitiş",dailyDigestLabel:"Günlük Özet",sendDigestAt:"Özeti şu saatte gönder",digestCollected:"Olaylar toplanacak ve bu saatte tek bir özet olarak gönderilecek",notificationEvents:"Bildirim Olayları",progressPercent:"(%25, %50, %75)",bedCooledAfterPrint:"(baskı tamamlandıktan sonra)",eventPriority:{sectionTitle:"ntfy Önceliği",helpNtfy:"Her etkin olay için bir öncelik seçin. ntfy uyarıları kademelendirmek için bunları kullanır (ses, görünürlük, push davranışı). Burada ayarlanmamış seviyeler ntfy sunucu varsayılanını kullanır.",min:"Min",low:"Düşük",default:"Varsayılan",high:"Yüksek",urgent:"Acil"},cancel:"İptal",save:"Kaydet",add:"Ekle",nameRequired:"Ad gerekli",fieldRequired:"{{field}} gerekli",phoneNumber:"Telefon Numarası",apiKey:"API Anahtarı",serverUrl:"Sunucu URL",topic:"Konu",authToken:"Yetkilendirme Belirteci",userKey:"Kullanıcı Anahtarı",appToken:"Uygulama Belirteci",priority:"Öncelik",botToken:"Bot Belirteci",chatId:"Sohbet ID",smtpServer:"SMTP Sunucusu",smtpPort:"SMTP Portu",security:"Güvenlik",authentication:"Kimlik Doğrulama",username:"Kullanıcı Adı",password:"Parola",fromEmail:"Gönderen E-posta",toEmail:"Alıcı E-posta",webhookUrl:"Webhook URL",payloadFormat:"Yük Formatı",authorization:"Yetkilendirme",titleFieldName:"Başlık Alanı Adı",messageFieldName:"Mesaj Alanı Adı",editTemplate:"Şablonu Düzenle: {{name}}",titleLabel:"Başlık",bodyLabel:"Gövde",titlePlaceholder:"Bildirim başlığı...",bodyPlaceholder:"Bildirim gövdesi...",availableVariables:"Kullanılabilir Değişkenler",clickToInsert:"Gövdede imleç konumuna eklemek için tıklayın",livePreview:"Canlı Önizleme",hide:"Gizle",show:"Göster",loadingPreview:"Önizleme yükleniyor...",enterTemplateContent:"Önizlemeyi görmek için şablon içeriğini girin",titlePreview:"Başlık:",bodyPreview:"Gövde:",resetToDefault:"Varsayılana Sıfırla",titleRequired:"Başlık gerekli",bodyRequired:"Gövde gerekli",notificationLog:"Bildirim Günlüğü",showFailedOnly:"Yalnızca başarısız",last24Hours:"Son 24 saat",last7Days:"Son 7 gün",last30Days:"Son 30 gün",last90Days:"Son 90 gün",justNow:"Az önce",noFailedNotifications:"Başarısız bildirim yok",noNotificationsLogged:"Kaydedilen bildirim yok",unknownProvider:"Bilinmeyen Sağlayıcı",logTitle:"Başlık",logMessage:"Mesaj",logError:"Hata",logProvider:"Sağlayıcı: {{type}}",logTime:"Saat: {{time}}",refresh:"Yenile",clearOld:"Eskileri Temizle",statsSummary:"Son {{days}} gün:",statsNotifications:"bildirim",statsSent:"{{count}} gönderildi",statsFailed:"{{count}} başarısız",eventTypes:{print_start:"Baskı Başladı",print_complete:"Baskı Tamamlandı",print_failed:"Baskı Başarısız",print_stopped:"Baskı Durduruldu",print_progress:"İlerleme",printer_offline:"Yazıcı Çevrimdışı",printer_error:"Yazıcı Hatası",filament_low:"Az Filament",maintenance_due:"Bakım Zamanı",test:"Test"},userEmail:{title:"Bildirimler",emailNotifications:"E-posta Bildirimleri",emailNotificationsDesc:"Kendi baskı işleriniz için e-posta bildirimleri alın. E-postalar Gelişmiş Kimlik Doğrulamada yapılandırılan sistem SMTP ayarları kullanılarak gönderilir.",sendingTo:"Bildirimler şuraya gönderilecek",noEmailWarning:"Hesabınızın e-posta adresi yok. Eklemek için bir yöneticiyle iletişime geçin.",printJobNotifications:"Baskı İşi Bildirimleri",printJobNotificationsDesc:"Gönderdiğiniz baskı işleri için hangi olayların e-posta bildirimlerini tetikleyeceğini seçin.",printJobStarts:"Baskı İşi Başlar",printJobStartsDesc:"Baskı işiniz başladığında bildirim alın.",printJobFinishes:"Baskı İşi Biter",printJobFinishesDesc:"Baskı işiniz başarıyla tamamlandığında bildirim alın.",printErrors:"Baskı Hataları",printErrorsDesc:"Baskı işiniz başarısız olduğunda veya bir hatayla karşılaştığında bildirim alın.",printJobStops:"Baskı İşi Durur",printJobStopsDesc:"Baskı işiniz iptal edildiğinde veya durdurulduğunda bildirim alın.",saveSuccess:"Bildirim tercihleri kaydedildi.",saveError:"Bildirim tercihleri kaydedilemedi."}},richTextEditor:{bold:"Kalın",italic:"İtalik",underline:"Altı Çizili",bulletList:"Madde İşaretli Liste",numberedList:"Numaralı Liste",alignLeft:"Sola Hizala",alignCenter:"Ortaya Hizala",alignRight:"Sağa Hizala",addLink:"Bağlantı Ekle",removeLink:"Bağlantıyı Kaldır"},externalLinks:{noLinksConfigured:"Yapılandırılmış harici bağlantı yok",deleteLink:"Bağlantıyı Sil",removeCustomIcon:"Özel simgeyi kaldır",openInNewTab:"Yeni sekmede aç",placeholders:{linkName:"Bağlantım"}},keyboardShortcuts:{title:"Klavye Kısayolları",navigation:"Navigasyon",archivesSection:"Arşivler",kProfilesSection:"K-Profilleri",generalSection:"Genel",shortcuts:{goToPrinters:"Yazıcılara Git",goToArchives:"Arşivlere Git",goToQueue:"Kuyruğa Git",goToStats:"İstatistiklere Git",goToProfiles:"Bulut Profillerine Git",goToSettings:"Ayarlara Git",focusSearch:"Aramaya odaklan",openUploadModal:"Yükleme modalini aç",clearSelection:"Seçimi temizle / odağı kaldır",contextMenu:"Kartlarda bağlam menüsü",refreshProfiles:"Profilleri yenile",newProfile:"Yeni profil",exitSelectionMode:"Seçim modundan çık",showHelp:"Bu yardımı göster"},footer:"Kapatmak için Esc'ye basın veya dışarı tıklayın"},notificationLog:{title:"Bildirim Günlüğü",events:{printStarted:"Baskı Başladı",printComplete:"Baskı Tamamlandı",printFailed:"Baskı Başarısız",printStopped:"Baskı Durduruldu",progress:"İlerleme",printerOffline:"Yazıcı Çevrimdışı",printerError:"Yazıcı Hatası",lowFilament:"Az Filament",maintenanceDue:"Bakım Zamanı",test:"Test"},timeAgo:{justNow:"Az önce",minutesAgo:"{{minutes}}dk önce",hoursAgo:"{{hours}}sa önce"}},restoreBackup:{title:"Yedeği Geri Yükle",restoring:"Geri yükleniyor...",restoreComplete:"Geri Yükleme Tamamlandı",restoreFailed:"Geri Yükleme Başarısız",importSettings:"Ayarları bir yedek dosyasından içe aktar",pleaseWait:"Verileriniz geri yüklenirken lütfen bekleyin",clickToSelect:"Yedek dosyası seçmek için tıklayın (.json veya .zip)",howDuplicateHandling:"Yinelenen yönetiminin nasıl çalıştığı:",categories:{printers:"Yazıcılar",smartPlugs:"Akıllı Prizler",notificationProviders:"Bildirim Sağlayıcıları",filaments:"Filamentler",archives:"Arşivler",pendingUploads:"Bekleyen Yüklemeler",settingsTemplates:"Ayarlar ve Şablonlar"},matchingInfo:{printers:"seri numarasına göre eşleşti",smartPlugs:"IP adresine göre eşleşti",notificationProviders:"ada göre eşleşti",filaments:"ad + tür + markaya göre eşleşti",archives:"içerik hash'ine göre eşleşti",pendingUploads:"dosya adına göre eşleşti",settingsTemplates:"her zaman üzerine yazılır"},replaceExisting:"Mevcut veriyi değiştir",keepExisting:"Mevcut veriyi koru",replaceDescription:"Zaten mevcut olan öğeleri yedek verisiyle üzerine yaz",keepDescription:"Yalnızca zaten mevcut olmayan öğeleri geri yükle",caution:"Dikkat:",cautionText:"Üzerine yazma, mevcut yapılandırmalarınızı yedek verisiyle değiştirecek. Güvenlik için yazıcı erişim kodları asla üzerine yazılmaz.",itemsRestored:"Geri Yüklenen Öğeler",itemsSkipped:"Atlanan Öğeler",restored:"Geri yüklendi",skipped:"Atlandı (zaten mevcut)",filesLabel:"Dosyalar (3MF, küçük resimler, vb.)",newApiKeysGenerated:"Yeni API Anahtarları Oluşturuldu",newApiKeysWarning:"Bu anahtarlar yalnızca bir kez gösterilir. Şimdi kopyalayın!",processingBackup:"Yedek dosyası işleniyor...",noDataFound:"Yedek dosyasında geri yüklenecek veri bulunamadı.",failedToRestore:"Yedek geri yüklenemedi. Lütfen dosya formatını kontrol edin."},backupExport:{title:"Yedeği Dışa Aktar",selectData:"Dahil edilecek veriyi seçin",selectAll:"Tümünü Seç",selectNone:"Hiçbirini Seçme",categoryDescriptions:{settings:"Dil, tema, güncelleme tercihleri",notifications:"ntfy, Pushover, Discord, vb.",templates:"Özel mesaj şablonları",smartPlugs:"Tasmota priz yapılandırmaları",externalLinks:"Harici servislere kenar çubuğu bağlantıları",printers:"Yazıcı bilgisi (erişim kodları hariç)",plateDetection:"Boş plaka referans görüntüleri",filaments:"Filament türleri ve maliyetleri",maintenance:"Özel bakım programları",archives:"Tüm baskı verileri + dosyalar (3MF, küçük resimler, fotoğraflar)",projects:"Projeler, BOM öğeleri ve ekler",pendingUploads:"İnceleme bekleyen sanal yazıcı yüklemeleri",apiKeys:"Webhook API anahtarları (içe aktarımda yeni anahtarlar oluşturulur)"},requiresPrinters:"Yazıcıların seçili olması gerekir",zipFileWarning:"ZIP dosyası oluşturulacak.",zipFileDescription:"Tüm 3MF dosyalarını, küçük resimleri, zaman atlamalı videoları ve fotoğrafları içerir. Bu biraz zaman alabilir ve büyük bir dosya oluşturabilir.",includeAccessCodes:"Erişim Kodlarını Dahil Et",includeAccessCodesDescription:"Başka bir makineye aktarmak için",includeAccessCodesWarning:"Erişim kodları düz metin olarak dahil edilecek. Bu yedek dosyasını güvende tutun!",categoriesSelected:"{{selectedCount}} kategori seçildi"},pendingUploads:{placeholders:{notes:"Bu baskı hakkında notlar ekleyin..."},discardUpload:"Yüklemeyi At",archiveAllUploads:"Tüm Yüklemeleri Arşivle",discardAllUploads:"Tüm Yüklemeleri At",archive:"Arşivle",timeAgo:{justNow:"Az önce",minutesAgo:"{{minutes}}dk önce",hoursAgo:"{{hours}}sa önce",daysAgo:"{{days}}g önce"}},apiBrowser:{placeholders:{requestBody:"JSON istek gövdesi...",searchEndpoints:"Uç noktalarda ara..."}},configureAmsSlot:{title:"AMS Yuvasını Yapılandır",slotConfigured:"Yuva Yapılandırıldı!",configuringSlot:"Yuva yapılandırılıyor:",slotLabel:"{{ams}} Yuva {{slot}}",searchPresets:"Ön ayarlarda ara...",colorPlaceholder:"Renk adı veya hex (örn., kahverengi, FF8800)",clearCustomColor:"Özel rengi temizle",noCloudPresets:"Bulut ön ayarı yok. Senkronize etmek için Bambu Cloud'a giriş yapın.",noPresetsAvailable:"Kullanılabilir ön ayar yok. Bambu Cloud'a giriş yapın veya yerel profilleri içe aktarın.",noMatchingPresets:"Eşleşen ön ayar bulunamadı.",custom:"Özel",builtin:"Yerleşik",settingsSentToPrinter:"Ayarlar yazıcıya gönderildi",filamentProfile:"Filament Profili",kProfileLabel:"K Profili (Basınç İlerlemesi)",filteringFor:"Şu için filtreleniyor: {{material}}",noKProfile:"K profili yok (varsayılan 0.020 kullan)",noMatchingKProfiles:"Eşleşen K profili bulunamadı. Varsayılan K=0.020 kullanılacak.",selectFilamentFirst:"Önce bir filament profili seçin",kFromCalibration:"Yazıcı kalibrasyonundan K={{value}}",customColorLabel:"Özel Renk (isteğe bağlı)",presetColors:"{{name}} renkleri:",showLessColors:"Daha az renk göster",showMoreColors:"Daha fazla renk göster",clear:"Temizle",hexLabel:"Hex: #{{hex}}",resetting:"Sıfırlanıyor...",resetSlot:"Yuvayı Sıfırla",cancel:"İptal",configuring:"Yapılandırılıyor...",configureSlot:"Yuvayı Yapılandır"},githubBackup:{title:"Git Yedekleme",history:"Geçmiş",downloadBackup:"Yedeği İndir",restoreBackup:"Yedeği Geri Yükle",noBackupsYet:"Henüz yedek yok"},emailSettings:{placeholders:{fromName:"BamBuddy"}},tagManagement:{searchTags:"Etiketlerde ara...",renameTag:"Etiketi yeniden adlandır",deleteTag:"Etiketi sil"},notificationTemplates:{placeholders:{title:"Bildirim başlığı...",body:"Bildirim gövdesi..."}},batchTag:{placeholders:{newTag:"Yeni etiket girin..."}},photoGallery:{deletePhoto:"Fotoğrafı Sil"},filamentHoverCard:{copySpoolUuid:"Makara UUID'sini kopyala"},kProfilesView:{hasNote:"Notu var",copyProfile:"Profili kopyala"},layout:{openMenu:"Menüyü aç",noPermissionSystemInfo:"Sistem bilgisini görüntüleme izniniz yok"},dashboard:{dragToReorder:"Yeniden sıralamak için sürükleyin",hideWidget:"Widget'ı gizle"},notificationProviderCard:{deleteNotificationProvider:"Bildirim Sağlayıcısını Sil"},fileManagerModal:{closeFileManager:"Dosya yöneticisini kapat",sortFiles:"Dosyaları sırala",goToParentFolder:"Üst klasöre git",threeView:"3B Görünüm"},embeddedCameraViewer:{refreshStream:"Akışı yenile",close:"Kapat",zoomOut:"Uzaklaştır",resetZoom:"Yakınlaştırmayı sıfırla",zoomIn:"Yakınlaştır",dragToResize:"Yeniden boyutlandırmak için sürükleyin"},timelapseViewer:{skipBack5s:"5 sn geri al",skipForward5s:"5 sn ileri al"},notificationProviders:{descriptions:{email:"SMTP e-posta bildirimleri",telegram:"Telegram botu üzerinden bildirimler",discord:"Webhook üzerinden Discord kanalına gönder",ntfy:"Ücretsiz, kendi barındırılabilir push bildirimleri",pushover:"Basit, güvenilir push bildirimleri",callmebot:"CallMeBot üzerinden ücretsiz WhatsApp bildirimleri",webhook:"Herhangi bir URL'ye genel HTTP POST"}},logViewer:{searchPlaceholder:"Mesaj veya günlükçü adı ara...",noLogEntries:"Günlük kaydı bulunamadı"},switchbarPopover:{noSwitchesInSwitchbar:"Anahtar çubuğunda anahtar yok"},projectPageModal:{placeholders:{title:"Başlık",designer:"Tasarımcı",license:"Lisans",description:"Açıklama girin...",profileTitle:"Profil Başlığı",profileDescription:"Profil açıklaması..."}},spoolmanSettings:{},time:{unknown:"-",waiting:"Bekliyor",justNow:"Az önce",now:"Şimdi",minsAgo:"{{count}}dk önce",inMins:"{{count}}dk sonra",hoursAgo:"{{count}}sa önce",inHours:"{{count}}sa sonra",daysAgo:"{{count}}g önce",inDays:"{{count}}g sonra"},spoolbuddy:{nav:{dashboard:"Gösterge Paneli",ams:"AMS",inventory:"Envanter",writeTag:"Yaz",settings:"Ayarlar"},status:{nfcReady:"NFC Hazır",nfcOff:"NFC Kapalı",offline:"Çevrimdışı",online:"Çevrimiçi",noPrinters:"Yazıcı yok",deviceOffline:"Cihaz Çevrimdışı",waitingConnection:"Cihaz bağlantısı bekleniyor...",systemReady:"Sistem Hazır",status:"Durum"},dashboard:{readyToScan:"Taramaya hazır",idleMessage:"Tanımlamak için bir makarayı tartıya yerleştirin",nfcHint:"NFC etiketi otomatik olarak okunacak",device:"Cihaz",syncWeight:"Ağırlığı Senkronize Et",weightSynced:"Senkronize edildi!",unknownTag:"Bilinmeyen Etiket",newTag:"Yeni Etiket Algılandı",onScale:"tartıda",linkSpool:"Makaraya Bağla",linkTagTitle:"Etiketi Makaraya Bağla",linkTag:"Etiketi Bağla",selectSpool:"Bu etiketi bağlamak için bir makara seçin:",noUntagged:"Etiketsiz makara bulunamadı",tagDetected:"Etiket algılandı",noTag:"Etiket yok",tagId:"Etiket",grossWeight:"Brüt ağırlık",spoolSize:"Makara boyutu",close:"Kapat",currentSpool:"Mevcut Makara",plateReady:"Plaka hazır: {{name}}",plateReadyLabel:"Temizlenmeye hazır plakalar",plateClearAction:"Temizle",plateClearedToast:"Plaka temizlendi olarak işaretlendi",plateClearFailed:"Plaka temizlendi olarak işaretlenemedi"},modal:{spoolDetected:"Makara Algılandı",assignToAms:"AMS'e Ata",syncWeight:"Ağırlığı Senkronize Et",weightSynced:"Senkronize edildi!",syncing:"Senkronize ediliyor...",newTagDetected:"Yeni Etiket Algılandı",addToInventory:"Envantere Ekle",assignToAmsTitle:"AMS'e Ata",selectSlot:"Bir yuva seçin",assign:"Ata",assigning:"Atanıyor...",assignSuccess:"Atandı!",assignPendingInsert:"Atandı. Makarayı yerleştirdiğinizde yuva yapılandırılacak.",assignError:"Makara atanamadı. Lütfen tekrar deneyin.",noPrinterSelected:"Bir yazıcı seçin...",noAmsDetected:"Bu yazıcıda AMS algılanmadı",slot:"Yuva"},weight:{noReading:"Okuma yok",stable:"Kararlı",measuring:"Ölçülüyor...",tare:"Dara",calibrate:"Kalibre Et"},spool:{remaining:"Kalan",material:"Malzeme",brand:"Marka",color:"Renk",coreWeight:"Çekirdek",labelWeight:"Etiket",scaleWeight:"Tartı",netWeight:"Net",lastUsed:"Son kullanım"},ams:{noData:"AMS algılanmadı",connectAms:"Filament yuvalarını görmek için bir AMS bağlayın",noPrinter:"Yazıcı seçilmedi",selectPrinter:"Üst çubuktan bir yazıcı seçin",printerDisconnected:"Yazıcı bağlantısı kesildi",humidity:"Nem",level:"Seviye",active:"Aktif",slot:"Yuva",empty:"Boş"},inventory:{search:"Makaralarda ara...",empty:"Envanterde makara yok",noResults:"Eşleşen makara yok",spools:"makara",addSpool:"Makara Ekle"},settings:{tabDevice:"Cihaz",tabDisplay:"Görüntü",tabScale:"Tartı",tabUpdates:"Güncellemeler",nfcReader:"NFC Okuyucu",type:"Tür",connection:"Bağlantı",notConnected:"N/A",deviceInfo:"Cihaz Bilgisi",hostname:"Ana Bilgisayar",uptime:"Çalışma Süresi",systemConfig:"Arka Uç ve Kimlik Doğrulama",backendUrl:"Bambuddy Arka Uç URL'si",apiToken:"API Belirteci",apiTokenPlaceholder:"API belirtecini girin",saveConfig:"Yapılandırmayı Kaydet",systemQueued:"Yapılandırma kuyruğa alındı.",nfcDiagnostic:"NFC Tanılaması",scaleDiagnostic:"Tartı Tanılaması",readTagDiagnostic:"Etiket Okuma Tanılaması",testNfc:"Okuyucuyu test et",testScale:"Doğruluğu test et",testReadTag:"Etiket oku",systemFieldsRequired:"Arka uç URL'si gerekli.",brightness:"Parlaklık",saved:"Kaydedildi",noBacklight:"DSI arka ışık algılanmadı. Parlaklık kontrolü DSI ekran gerektirir.",screenBlank:"Ekran Kapanma Zaman Aşımı",screenBlankDesc:"Hareketsizlik sonrası ekran kapanır. Uyandırmak için dokunun.",displayNote:"Parlaklık bir yazılım filtresi olarak uygulanır.",scaleCalibration:"Tartı Kalibrasyonu",currentWeight:"Mevcut ağırlık",tareOffset:"Dara",calFactor:"Faktör",knownWeight:"Bilinen ağırlık",calStep1:"Tartıdan tüm öğeleri kaldırın ve Sıfır Ayarla'ya basın.",calStep2:"Bilinen ağırlığı tartıya yerleştirin.",setZero:"Sıfır Ayarla",calibrateNow:"Kalibre Et",calibrated:"Kalibre edildi",tareSet:"Dara komutu gönderildi. Cihaz bekleniyor...",tareComplete:"Dara tamam!",tareTimedOut:"Dara zaman aşımına uğradı — SpoolBuddy daemon çalışıyor mu?",tareFailed:"Dara komutu gönderilemedi",zeroSet:"Sıfır noktası ayarlandı. Bilinen ağırlığı tartıya yerleştirin.",calibrationDone:"Kalibrasyon tamamlandı!",calibrationFailed:"Kalibrasyon başarısız",lastCalibrated:"Son kalibrasyon",stable:"Kararlı",settling:"Sabitleniyor...",firmware:"Firmware",scale:"Tartı",noDevice:"SpoolBuddy cihazı bulunamadı",daemonVersion:"Daemon Sürümü",currentVersion:"Mevcut",versionPending:"Daemon bekleniyor...",checking:"Kontrol ediliyor...",checkUpdates:"Güncellemeleri Kontrol Et",updateAvailable:"Güncelleme mevcut",updateInstructions:"SSH üzerinden güncelle: yükseltmek için SpoolBuddy yükleme betiğini çalıştırın.",upToDate:"Güncel",includeBeta:"Beta sürümleri dahil et"},writeTag:{tabExisting:"Mevcut Makara",tabNew:"Yeni Makara",tabReplace:"Etiketi Değiştir",searchPlaceholder:"Malzeme, renk, marka ile ara...",noUntaggedSpools:"Etiketsiz makara yok",noTaggedSpools:"Etiketli makara yok",selectSpool:"Bir makara seçin, ardından okuyucuya boş bir NTAG yerleştirin",placeTag:"Okuyucuya bir NTAG yerleştirin",tagReady:"Etiket algılandı — yazmaya hazır",writeTag:"Etiketi Yaz",replaceTag:"Etiketi Değiştir",writing:"Etiket yazılıyor...",waiting:"SpoolBuddy bekleniyor...",writeSuccess:"Etiket başarıyla yazıldı!",writeFailed:"Yazma başarısız",queueFailed:"Yazma komutu kuyruğa alınamadı",tryAgain:"Tekrar Dene",cancel:"İptal",replaceWarning:"Eski etiket bağlantısı kaldırılacak. Yeni etiket yerini alacak.",deviceOffline:"SpoolBuddy çevrimdışı",material:"Malzeme",colorName:"Renk Adı",color:"Renk",brand:"Marka",weight:"Ağırlık (g)",createSpool:"Makara Oluştur",creating:"Oluşturuluyor...",spoolCreated:"Makara oluşturuldu! Yazmaya hazır.",createFailed:"Makara oluşturulamadı",incompleteDataWarning:"Etiket eksik Spoolman verisiyle yazıldı"},quickMenu:{printerPower:"Yazıcı Gücü",systemControls:"Sistem",restartDaemon:"Daemon'u Yeniden Başlat",restartBrowser:"Tarayıcıyı Yeniden Başlat",reboot:"Yeniden Başlat",shutdown:"Kapat",swipeToClose:"Kapatmak için aşağı kaydır",confirmTitle:"Onayla",confirmShutdown:"SpoolBuddy'yi kapatmak istediğinizden emin misiniz? Tekrar açmak için fiziksel erişime ihtiyacınız olacak.",confirmReboot:"SpoolBuddy'yi yeniden başlatmak istediğinizden emin misiniz?",confirmRestartDaemon:"SpoolBuddy daemon yeniden başlatılsın mı? NFC ve tartı geçici olarak kullanılamayacak.",confirmRestartBrowser:"Kiosk tarayıcısı yeniden başlatılsın mı? Ekran kısa bir süre kararacak.",confirm:"Onayla",confirmPlugOn:"{{name}} açılsın mı?",confirmPlugOff:"{{name}} kapatılsın mı?",turnOn:"Aç",turnOff:"Kapat"}},diagnostic:{modalTitle:"Bağlantı tanılaması — {{name}}",running:"Tanılama çalışıyor...",runningElapsed:"Tanılama çalışıyor... ({{elapsed}}s)",waitingForReportHint:"Yazıcının durum raporu yayınlamasını bekliyor — bu işlem en fazla {{max}} saniye sürebilir.",runFailed:"Tanılama çalıştırılamadı: {{error}}",retry:"Tekrar çalıştır",runButton:"Tanılamayı çalıştır",sectionTitle:"Bağlantı Tanılaması",sectionDescription:"Bir yazıcının neden bağlanmadığını veya yazdırmadığını kontrol edin — port erişilebilirliği, LAN geliştirici modu, Docker ağ modu ve kimlik bilgileri.",noPrinters:"Yapılandırılmış yazıcı yok.",overall:{ok:"Sorun bulunamadı — yazıcı bağlantısı sağlıklı görünüyor.",warnings:"Yazıcı çalışmalı, ancak bazı şeyler dikkat gerektiriyor.",problems:"Yazıcının neden bağlanmadığını veya yazdırmadığını açıklayan sorunlar bulundu."},check:{port_mqtt:{title:"Kontrol portu (MQTT 8883)",pass:"Erişilebilir — yazıcı kontrol bağlantılarını kabul ediyor.",fail:"Port 8883 erişilemez. Yazıcı kapalı, farklı bir IP adresinde veya bir güvenlik duvarı engelliyor. Yazıcı IP'sini ve hiçbir şeyin port 8883'ü engellemediğini doğrulayın."},port_ftps:{title:"Dosya aktarım portu (FTPS 990)",pass:"Erişilebilir — baskı dosyaları gönderme çalışacak.",warn:"Port 990 erişilemez. İzleme yine çalışabilir, ancak yazıcıya baskı gönderme başarısız olacak. Port 990'ın engellenmediğinden emin olun."},port_rtsps:{title:"Kamera portu (RTSPS 322)",pass:"Erişilebilir — kamera akışı çalışacak.",warn:"Port 322 erişilemez. Canlı kamera görünümü çalışmayacak. Bu, baskıyı etkilemez."},network_mode:{title:"Docker ağ modu",pass:"Ana bilgisayar ağ modunda çalışıyor.",warn:'Bambuddy, Docker köprü ağı kullanılarak çalışıyor. Yazıcı keşfi ve Sanal Yazıcı, ana bilgisayar ağ modu gerektirir — konteyneri "network_mode: host" ile yeniden oluşturun.',skip:"Docker'da çalışmıyor — uygulanamaz."},subnet:{title:"Ağ alt ağı",pass:"Yazıcı ve Bambuddy aynı alt ağda.",warn:"Yazıcı ({{printer_ip}}) ve Bambuddy ({{host_ip}}) farklı alt ağlarda. Alt ağlar arasında yönlendirme yapılandırılmazsa birbirlerine erişemeyebilirler.",skip:"Alt ağ belirlenemedi — atlandı."},mqtt_auth:{title:"Yazıcı kimlik bilgileri",pass:"Yazıcı bağlantıyı kabul etti.",fail:"Yazıcı erişilebilir ancak bağlantıyı reddetti. Büyük olasılıkla erişim kodu veya seri numarası yanlış. Erişim kodu, Geliştirici Modu her açılıp kapatıldığında değişir — yazıcı ekranından yeniden kopyalayın.",skip:"Kontrol edilmedi — yazıcıya erişilemedi."},developer_mode:{title:"LAN Geliştirici Modu",pass:"Geliştirici Modu etkin.",fail:"Yazıcıda Geliştirici Modu KAPALI. Yazıcının LAN ayarlarında etkinleştirin — ve OK ile onaylayın. Bu olmadan baskılar başlamayacak.",skip:"Kontrol edilemedi — yazıcıya canlı bir bağlantı gerektirir."},printer_publishing:{title:"Yazıcı durum yayını yapıyor",pass:"Yazıcı durum güncellemelerini yayınlıyor — AMS, filamentler ve K profilleri dilimleyiciye doğru şekilde yansıtılacak.",fail:"MQTT aracısı bağlantıyı kabul etti, ancak yazıcı hiç durum raporu yayınlamadı. Bu neredeyse her zaman yanlış ya da büyük/küçük harf hatalı bir seri numarasından kaynaklanır — device//report konusu büyük/küçük harfe duyarlıdır. Yazıcı ayarlarındaki seri numarasını cihazın ekranındaki ile karşılaştırarak doğrulayın.",skip:"Kontrol edilemedi — yazıcıya canlı bir bağlantı gerektirir."}}},systemHealth:{sectionTitle:"Sistem Sağlığı",sectionDescription:"Bir destek talebine dönüşmeden önce genellikle kendi başınıza düzeltebileceğiniz bilinen sorunlar için son günlükleri tarar.",rescan:"Yeniden tara",clean:"Son {{times}} günlük kaydında bilinen sorun bulunamadı.",logUnavailable:"Dosya günlüğü devre dışı, bu nedenle günlükler taranamıyor. Bu kontrolü kullanmak için dosya günlüğünü etkinleştirin.",learnMore:"Nasıl düzeltilir",fixLabel:"Düzeltme:",occurrences:"{{times}}× görüldü — son {{lastSeen}}'de",category:{layer8:"Bunu düzeltebilirsiniz",environment:"Ortam",bug:"Lütfen bunu bildirin"},signature:{"ftp-auth-rejected":{name:"Yazıcı erişim kodunu reddetti",cause:"Yazıcı dosya aktarım girişini reddetti. Erişim kodu yanlış veya Geliştirici Modu açılıp kapatıldıktan sonra değişti.",fix:"Erişim kodunu yazıcı ekranından (LAN ayarları) yeniden kopyalayın ve Bambuddy'deki yazıcının ayarlarında güncelleyin."},"ftp-connection-timeout":{name:"Dosya aktarım bağlantısı zaman aşımına uğradı",cause:"Bambuddy yazıcının dosya aktarım portuna (FTPS 990) erişemedi. Port engellendi veya yazıcı kapalı ya da başka bir alt ağda.",fix:"Bambuddy ile yazıcı arasında hiçbir şeyin port 990'ı engellemediğinden ve her ikisinin de aynı ağda olduğundan emin olun."},"ftp-ssl-error":{name:"Güvenli dosya aktarım el sıkışması başarısız",cause:"Yazıcının dosya aktarım sunucusuyla TLS el sıkışması başarısız oldu. Bu genellikle bir güvenlik duvarı veya eski yazıcı firmware'idir.",fix:"Yazıcı firmware'ini güncelleyin ve port 990'daki bağlantıyı hiçbir güvenlik duvarı veya proxy'nin engellemediğini kontrol edin."},"mqtt-connection-flapping":{name:"Yazıcı bağlantısı sürekli düşüyor",cause:"Kontrol bağlantısı (MQTT 8883) tekrar tekrar bağlantısı kesiliyor ve yeniden bağlanıyor — genellikle zayıf bir ağ yolu veya kısmen engellenmiş bir port.",fix:"Yazıcıdaki Wi-Fi sinyalini kontrol edin, kablolu bağlantı tercih edin ve port 8883'ün güvenilir bir şekilde erişilebilir olduğundan emin olun."},"camera-connection-refused":{name:"Kamera akışına erişilemez",cause:"Canlı kameraya RTSPS 322 portunda erişilemedi. Port engellendi veya yazıcıda kamera veya LAN canlı görünümü kapalı.",fix:"Yazıcıda kamerayı ve LAN canlı görünümünü etkinleştirin ve port 322'nin engellenmediğinden emin olun. Bu, baskıyı etkilemez."},"database-locked":{name:"Veritabanı yazma çekişmesi",cause:'SQLite veritabanı yük altında "veritabanı kilitli" hatalarıyla karşılaşıyor — aynı anda birkaç yazıcı çalıştırırken yaygındır.',fix:"Bambuddy'yi harici bir PostgreSQL veritabanına geçirin. Dokümantasyondaki PostgreSQL kılavuzuna bakın."}}},vpDiagnostic:{title:"Kurulum kontrolü — {{name}}",runButton:"Kurulum kontrolünü çalıştır",running:"Kurulum kontrolü çalışıyor...",runFailed:"Kurulum kontrolü çalıştırılamadı: {{error}}",retry:"Tekrar çalıştır",overall:{ok:"Tüm kontroller geçti — bu sanal yazıcı doğru şekilde kurulmuş.",warnings:"Sanal yazıcı çalışmalı, ancak bazı şeyler dikkat gerektiriyor.",problems:"Dilimleyicinin bu sanal yazıcıyı neden göremediğini veya kullanamadığını açıklayan sorunlar bulundu."},check:{enabled:{title:"Sanal yazıcı etkin",fail:"Bu sanal yazıcı kapatılmış. Keşfedilebilir hale getirmek için açın."},running:{title:"Servisler çalışıyor",fail:"Sanal yazıcı etkin ancak servisleri çalışmıyor. Bambuddy günlüğünü kontrol edin — bir bind IP çakışması veya bir izin hatası genellikle onları durdurur."},bind_interface:{title:"Bind ağ arayüzü",fail:"Bind arayüzü ayarlanmamış veya artık bu ana bilgisayarda mevcut değil. Bind Arayüzü açılır menüsünde mevcut bir arayüz seçin."},access_code:{title:"Erişim kodu ayarlandı",fail:"Erişim kodu ayarlanmamış. Dilimleyiciye burada ayarladığınız aynı 8 karakterli erişim kodunun verilmesi gerekir."},target_printer:{title:"Hedef yazıcı",fail:"Hedef yazıcı seçilmemiş. Proxy modu iletmek için gerçek bir yazıcıya ihtiyaç duyar.",warn:"Hedef yazıcı şu anda çevrimdışı — yeniden bağlandığında proxy devam edecek."},port_ftps:{title:"Dosya yükleme servisi (port {{port}})",fail:"Bind IP'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyici dosya yükleyemez. Bu arayüzde bir port çakışması genellikle nedendir."},port_mqtt:{title:"Kontrol servisi (port {{port}})",fail:"Bind IP'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyici bağlanamaz veya durum gösteremez."},port_bind:{title:"Keşif servisi (port {{port}})",fail:"Bind IP'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyicinin keşif el sıkışması başarısız oluyor."},certificate:{title:"TLS sertifikası",pass:"Sertifika hazır. Bambuddy CA sertifikasının (yukarıda) dilimleyicinizin güven deposuna içe aktarıldığından emin olun.",fail:"Bu sanal yazıcı için TLS sertifikası eksik. Bambuddy veri dizininin yazılabilir olduğunu kontrol edin."}}},bugReport:{title:"Hata Bildir",description:"Açıklama",descriptionPlaceholder:"Ne ters gitti? Lütfen sorunu tanımlayın...",email:"E-posta (isteğe bağlı)",emailPlaceholder:"sizin@email.com",emailPrivacy:"Sağlanırsa, e-postanız GitHub sorunundaki daraltılmış bir bölümde dahil edilecek, böylece bakımcı takip edebilir.",screenshot:"Ekran Görüntüsü",uploadOrPaste:"Bir görüntü yükleyin, yapıştırın veya sürükleyin",dataCollectedSummary:"Rapora hangi veriler dahil edilir?",dataIncluded:"Dahil:",dataIncludedList:"Uygulama sürümü, OS, mimari, Python sürümü, veritabanı istatistikleri (yalnızca sayılar), yazıcı modelleri, nozul sayıları, firmware sürümleri, bağlantı durumu, entegrasyon durumu (Spoolman, MQTT, HA), hassas olmayan ayarlar, ağ arayüzü sayısı, Docker ayrıntıları, bağımlılık sürümleri.",dataNeverIncluded:"Asla dahil edilmez:",dataNeverIncludedList:"Yazıcı adları, seri numaraları, erişim kodları, parolalar, IP adresleri, e-posta adresleri, API anahtarları, belirteçler, webhook URL'leri, ana bilgisayar adları veya kullanıcı adları.",submit:"Gönder",startLogging:"Hata Ayıklama Kaydını Başlat",stepEnableLogging:"Hata ayıklama kaydı etkin",stepReproduce:"Sorunu şimdi yeniden oluşturun",stepStopLogging:"Durdur ve raporu gönder",stopAndSubmit:"Durdur ve Gönder",maxDuration:"{{minutes}} dk sonra otomatik durur",stoppingLogs:"Günlükler toplanıyor ve gönderiliyor...",submitting:"Hata raporu gönderiliyor...",submittingStepConnection:"Yazıcı bağlantı kontrolleri çalıştırılıyor",submittingStepVirtualPrinters:"Sanal yazıcı kurulum kontrolleri çalıştırılıyor",submittingStepLogScan:"Son günlüklerde bilinen sorunlar taranıyor",submittingStepSubmit:"Rapor GitHub'a gönderiliyor",submitSuccess:"Hata raporu başarıyla gönderildi!",submitFailed:"Hata raporu gönderilemedi",diagnosticChecking:"Yazıcı bağlantıları kontrol ediliyor...",diagnosticHealthy:"Bağlantı kontrolü başarılı — yazıcılarınızda sorun bulunamadı.",diagnosticSummary:"{{total}} yazıcıdan {{problems}} tanesinde bağlantı sorunu var",diagnosticIntro:"Bir veya daha fazla yazıcının sorununuza neden olabilecek bir bağlantı sorunu var. Düzeltmeyi görmek için aşağıda bir yazıcıyı genişletin — bunu çözmek, hata raporu olmadan sorunu çözebilir. Yine de aşağıda bir rapor gönderebilirsiniz.",logHealthSummary:"Günlüklerinizde bilinen sorunlar bulundu",logHealthIntro:"Son günlükler bilinen sorunlarla eşleşiyor. Aşağıdaki düzeltmeleri kontrol edin — bunları çözmek, hata raporu olmadan sorununuzu çözebilir. Yine de aşağıda bir rapor gönderebilirsiniz.",thankYou:"Teşekkürler!",submitted:"Hata raporunuz gönderildi.",viewIssue:"Sorunu Görüntüle",unexpectedError:"Beklenmedik bir hata oluştu"},failureDetection:{title:"AI Başarısızlık Algılama",description:"Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.",mlUrl:"Obico ML API URL'si",mlUrlHint:"Kendi barındırılan Obico ml_api konteynerinizin temel URL'si (örn. http://192.168.1.10:3333).",test:"Test",testSuccess:"ML API erişilebilir ve sağlıklı.",testFailed:"ML API'ye erişilemedi.",sensitivity:"Hassasiyet",sensitivityLow:"Düşük (daha az yanlış pozitif)",sensitivityMedium:"Orta (dengeli)",sensitivityHigh:"Yüksek (erken algıla, daha fazla yanlış pozitif)",sensitivityHint:"Uyarıları ve başarısızlıkları tetikleyen güven eşiklerini ayarlar.",action:"Algılanan başarısızlıkta eylem",actionNotify:"Yalnızca bildir",actionPause:"Baskıyı duraklat",actionPauseOff:"Duraklat ve gücü kes",pollInterval:"Sorgulama aralığı (saniye)",pollIntervalHint:"Baskı yaparken her yazıcının ne sıklıkta kontrol edileceği. Minimum 5sn, maksimum 120sn.",externalUrlMissing:"Harici URL ayarlanmamış.",externalUrlHint:"ML API, kamera anlık görüntüsünü URL üzerinden alır. ML API konteyneri Bambuddy'ye erişebilsin diye Genel ayarlarda Harici URL'yi ayarlayın.",perPrinterTitle:"İzlenen Yazıcılar",perPrinterHint:"Algılama servisinin hangi yazıcıları izleyeceğini seçin.",monitorAll:"Tüm bağlı yazıcıları izle",statusTitle:"Durum",serviceRunning:"Servis çalışıyor",thresholds:"Düşük / Yüksek eşikler",activePrinters:"Aktif baskılar",noActivePrints:"Şu anda çalışan baskı yok.",historyTitle:"Son Algılamalar",noHistory:"Henüz algılama yok."},makerworld:{title:"MakerWorld",description:"Bambu Handy uygulamasına gitmeden — doğrudan Bambuddy'den içe aktarmak ve yazdırmak için bir MakerWorld model URL'si yapıştırın.",pasteUrlHeader:"MakerWorld'den İçe Aktar",pasteUrlPlaceholder:"https://makerworld.com/en/models/… veya herhangi bir MakerWorld bağlantısını yapıştırın",resolveButton:"Çöz",signInRequiredTitle:"İndirme için Bambu Cloud girişi gerekli",signInRequiredBody:"Model ayrıntılarına anonim olarak göz atabilirsiniz, ancak MakerWorld 3MF dosyalarını indirmek için bir Bambu Cloud hesabı gerektirir.",openCloudSettings:"Bulut ayarlarını aç",untitledModel:"Adsız model",byCreator:"{{name}} tarafından",downloadsCount:"{{count}} indirme",licensePrefix:"Lisans",alreadyImported:"Zaten kütüphanede",openOnMakerworld:"MakerWorld'de aç",alreadyInLibrary:"Bu model zaten kütüphanenizde — Dosya Yöneticisi → MakerWorld'de bulun",importSuccess:"{{filename}} içe aktarıldı — Dosya Yöneticisi → MakerWorld'e kaydedildi",platesHeader:"Plakalar ({{count}})",plateDefaultName:"Plaka {{n}}",materialCount:"{{count}} filament",amsRequired:"AMS gerekli",slicedFor:"{{printer}} için dilimlendi",alsoCompatible:"Ayrıca uyumlu olarak işaretlendi: {{printers}}",importToLibrary:"Kaydet",sliceIn:"Kaydet ve {{slicer}}'de Dilimle",disclaimer:"MakerWorld entegrasyonu topluluk tarafından belgelenen API uç noktalarını kullanır. Bambuddy, MakerWorld veya Bambu Lab ile ilişkili veya onaylı değildir.",lastImportSuccess:"Kütüphanenize içe aktarıldı",lastImportAlreadyInLibrary:"Zaten kütüphanenizde",viewInLibrary:"Dosya Yöneticisinde Görüntüle",openInBambuStudio:"Bambu Studio'da Aç",openInOrcaSlicer:"OrcaSlicer'da Aç",importTo:"Dosya yöneticisine içe aktar",recentImportsHeader:"Son içe aktarımlar",phaseResolving:"Çözülüyor",phaseDownloading:"İndiriliyor",folderAuto:"MakerWorld (varsayılan)",importAll:"Tümünü içe aktar",importAllProgress:"{{current}}/{{total}} içe aktarılıyor",openGallery:"Görüntü galerisini aç",galleryPrev:"Önceki görüntü",galleryNext:"Sonraki görüntü",deleteImport:"Kütüphaneden kaldır",importDeleting:"Kaldırılıyor…",importDeleted:"Kütüphaneden kaldırıldı",confirmDelete:"{{filename}} kütüphaneden kaldırılsın mı? Bu, yerel dosyayı siler ancak plaka MakerWorld'den yeniden içe aktarılabilir.",errors:{resolveFailed:"O MakerWorld URL'si çözülemedi.",downloadFailed:"İndirme başarısız. Lütfen tekrar deneyin.",deleteFailed:"Dosya kütüphaneden kaldırılamadı."}},gcodeViewer:{back:"Geri",backToArchives:"Baskı Arşivlerine Dön",backToFiles:"Dosya Yöneticisine Dön"},libraryTrash:{title:"Çöp Kutusu",headerButton:"Çöp",headerTooltip:"Çöpe taşınan dosyaları görüntüle",backToFiles:"Dosya Yöneticisine Dön",subtitleAdmin:"Silinen dosyalar burada {{days}} gün kalır, ardından otomatik silinir. Bu görünüm tüm kullanıcılar için çöpteki dosyaları gösterir.",subtitleUser:"Silinen dosyalar burada {{days}} gün kalır, ardından otomatik silinir.",loading:"Çöp yükleniyor…",loadError:"Çöp yüklenemedi.",empty:"Çöp boş.",summary:"{{count}} dosya · {{size}}",emptyTrash:"Çöpü boşalt",restore:"Geri Yükle",purgeNow:"Şimdi sil",autoPurgeIn:"{{when}} içinde otomatik silinir",days:"gün",retentionLabel:"Otomatik sil",selectAll:"Tümünü seç",selectOne:"{{filename}} seç",selectionCount:"{{count}} seçildi",bulkRestore:"Seçilenleri geri yükle",bulkPurge:"Seçilenleri sil",col:{filename:"Dosya",folder:"Klasör",size:"Boyut",deleted:"Çöpe taşındı",autoPurge:"Otomatik silinir",owner:"Sahip",actions:"İşlemler"},confirm:{purgeTitle:"Kalıcı olarak silinsin mi?",purgeBody:"{{filename}} diskten silinecek ve geri yüklenemeyecek.",emptyTitle:"Çöp boşaltılsın mı?",emptyBody:"Tüm {{count}} dosya diskten silinecek. Bu geri alınamaz.",bulkPurgeTitle:"Seçili dosyalar kalıcı olarak silinsin mi?",bulkPurgeBody:"Seçili {{count}} dosya diskten silinecek ve geri yüklenemeyecek.",cta:"Kalıcı olarak sil"},toast:{restored:"Dosya geri yüklendi.",restoreFailed:"Dosya geri yüklenemedi.",purged:"Dosya kalıcı olarak silindi.",purgeFailed:"Dosya silinemedi.",emptied:"{{count}} dosya çöpten silindi.",emptyFailed:"Çöp boşaltılamadı.",retentionSaved:"Otomatik silme {{days}} gün olarak ayarlandı.",retentionFailed:"Saklama ayarı kaydedilemedi.",bulkRestored:"{{count}} dosya geri yüklendi.",bulkPurged:"{{count}} dosya silindi."}},libraryPurge:{title:"Eski dosyaları temizle",headerButton:"Eskileri temizle",headerTooltip:"Eski dosyaları toplu olarak çöpe taşı",description:"Kütüphanenizden eski dosyaları tek seferde temizleyin. Baskı geçmişi olan dosyalar son baskı tarihlerine göre, hiç yazdırılmamış dosyalar yükleme tarihlerine göre yaşlandırılır.",ageLabel:"Şundan eski dosyaları taşı",days:"gün",includeNeverPrinted:"Hiç yazdırılmamış dosyaları dahil et",effectsTitle:"Temizle'ye tıkladığınızda ne olur",effect1:"Eşleşen dosyalar Çöp'e taşınır — henüz diskten silinmezler.",effect2:"Saklama penceresi sona erene kadar Çöp'ten herhangi bir zamanda geri yükleyebilirsiniz.",effect3:"Saklama sonrası, çöp süpürücü onları kalıcı olarak diskten kaldırır.",effect4:"Harici (bağlı) klasörlerdeki dosyalar atlanır — Bambuddy asla sahibi olmadığı bayt'ları silmez.",previewLoading:"Kaç dosyanın eşleştiği kontrol ediliyor…",previewFailed:"Temizleme önizlenemedi.",previewSummary:"{{count}} dosya · {{size}} çöpe taşınacak",andMore:"…ve {{count}} daha",warning:"Çöpe taşınan dosyalar saklama penceresi sona erene kadar depolama alanını kullanmaya devam eder. Diski hemen boşaltmak için sonradan Çöp'ü boşaltın.",confirmCta:"{{count}} dosyayı çöpe taşı",purging:"Çöpe taşınıyor…",toast:{success:"{{count}} dosya çöpe taşındı.",failed:"Dosyalar temizlenemedi."}},libraryAutoPurge:{enableLabel:"Eski dosyaları otomatik temizle",enableDescription:"Yönetici temizliğini günde bir kez çalıştırır. Dosyalar önce Çöp'e gider — hemen silinmezler.",ageLabel:"Şundan eski dosyaları otomatik temizle",ageDescription:"Minimum 7 gün, maksimum 10 yıl. Manuel Temizle düğmesiyle aynı yaş kuralını kullanır.",days:"gün",includeNeverPrinted:"Hiç yazdırılmamış dosyaları dahil et",saveFailed:"Otomatik temizleme ayarları kaydedilemedi."},archivePurge:{headerButton:"Eskileri temizle",headerTooltip:"Eski arşivleri toplu olarak sil",title:"Eski arşivleri temizle",description:"Eski baskı geçmişini temizleyin. Her arşiv en son baskı tamamlamasına göre yaşlandırılır — bir arşivi yeniden yazdırmak yaşını yeniler, bu nedenle aktif çalışma asla temizlenmez.",ageLabel:"Son şu kadar günde yazdırılmamış arşivleri sil",days:"gün",effectsTitle:"Temizle'ye tıkladığınızda ne olur",effect1:"Eşleşen her arşiv listelerden gizlenir ve dosyaları diskten kaldırılır (3MF, küçük resim, zaman atlamalı video, kaynak 3MF, F3D tasarım dosyası, fotoğraflar).",effect2:"Arşiv satırı veritabanında kalır, böylece Hızlı İstatistikler filament, süre, maliyet ve enerji katkısını korur — tek arşiv silme varsayılanı ile aynı.",effect3:'Hızlı İstatistik katkısını da kaldırmak için aşağıdaki "Ayrıca istatistiklerden kaldır"ı işaretleyin (tek arşiv silme seçeneğiyle eşleşir). O yol geri alınamaz.',effect4:"Bir arşivi yeniden yazdırmak yaş saatini yeniler, bu nedenle hâlâ kullandığınız arşivler güvendedir.",purgeStatsLabel:"Ayrıca istatistiklerden kaldır",purgeStatsHint:"Eşleşen arşivleri Hızlı İstatistiklerden (filament, süre, maliyet, enerji) düşürür. Bu olmadan, Hızlı İstatistikler her katkıyı korur ve yalnızca dosyalar diskten ayrılır.",previewLoading:"Kaç arşivin eşleştiği kontrol ediliyor…",previewFailed:"Temizleme önizlenemedi.",previewSummary:"{{count}} arşiv · {{size}} kaldırılacak",andMore:"…ve {{count}} daha",warning:"Dosyalar diskten kaldırılır ve geri yüklenemez. Devam etmeden önce saklamak istediğiniz her şeyi indirin veya favoriye ekleyin.",confirmCta:"{{count}} arşivi kaldır",purging:"Kaldırılıyor…",toast:{success:"{{count}} arşiv kaldırıldı.",failed:"Arşivler temizlenemedi."}},archiveAutoPurge:{enableLabel:"Eski arşivleri otomatik temizle",enableDescription:"Günde bir kez, eşik içinde yazdırılmamışsa arşivleri listelerden gizler ve dosyalarını diskten kaldırır. Bir arşivi yeniden yazdırmak saati sıfırlar.",ageLabel:"Son şu kadar günde yazdırılmamış arşivleri otomatik sil",ageDescription:"Minimum 7 gün, maksimum 10 yıl. En son baskı tamamlamasına göre — bir arşivi yeniden yazdırmak yaşını yeniler. 3MF, küçük resim, zaman atlamalı video, kaynak 3MF, F3D ve fotoğrafları kaldırır.",days:"gün",purgeStatsLabel:"Ayrıca istatistiklerden kaldır",purgeStatsDescription:"Etkinleştirildiğinde, günlük süpürücü her temizlenen arşivi de Hızlı İstatistiklerden (filament, süre, maliyet, enerji) düşürür. Varsayılan kapalı — Hızlı İstatistikler katkıyı korur, yalnızca dosyalar diskten ayrılır.",runNow:"Arşivleri şimdi temizle",saveFailed:"Otomatik temizleme ayarları kaydedilemedi."},cameraTokens:{title:"Kamera API Belirteçleri",navTitle:"Kamera API belirteçleri",description:"Kamera akışını Home Assistant, Frigate, kiosklar veya kararlı bir URL'ye ihtiyaç duyan başka herhangi bir araca gömmek için uzun ömürlü belirteçler. Her belirteç yalnızca kamera akışı içindir ve herhangi bir zamanda iptal edilebilir.",loading:"Yükleniyor…",confirmRevoke:{title:"Bu belirteç iptal edilsin mi?",body:'"{{name}}" kullanan herhangi bir cihaz hemen erişimini kaybedecek. Bu geri alınamaz.',cancel:"İptal",confirm:"İptal Et"},create:{title:"Yeni belirteç oluştur",nameLabel:"Belirteç adı",namePlaceholder:"örn. Home Assistant",daysLabel:"Sona erene kadar gün",submit:"Oluştur",hint:"Maksimum ömür 365 gün. Belirteç değeri oluşturmada yalnızca bir kez gösterilir — şimdi kopyalayın."},created:{title:"Belirteç oluşturuldu — şimdi kopyalayın",warning:"Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.",copy:"Kopyala",dismiss:"Kaydettim"},list:{myTitle:"Belirteçlerim",allTitle:"Tüm kullanıcılar (yönetici görünümü)",empty:"Henüz belirteç yok.",name:"Ad",owner:"Sahip",prefix:"Önek",created:"Oluşturuldu",expires:"Sona Erer",lastUsed:"Son kullanım",revoke:"İptal Et",expired:"Süresi Doldu"},toast:{created:"Belirteç oluşturuldu",createFailed:"Belirteç oluşturulamadı",revoked:"Belirteç iptal edildi",revokeFailed:"Belirteç iptal edilemedi",loadFailed:"Belirteçler yüklenemedi",copied:"Panoya kopyalandı",copyFailed:"Kopyalama başarısız — manuel olarak seçip kopyalayın"}},forecast:{title:"Tahmin",noSpools:"Aktif makara bulunamadı. Tahmin verisini görmek için envanterinize makara ekleyin.",noUsageData:"Kullanım verisi mevcut değil — stok zaman çizelgesi öngörülemiyor.",sku:"SKU",material:"Malzeme",stock:"Stok",dailyRate:"Oran",daysLeft:"Kalan Gün",emptyBy:"Şu Tarihte Boş",reorderBy:"Şu Tarihte Sipariş",actions:"İşlemler",trend:"Trend",estimated:"Tahmini",noData:"Veri yok",timeframe:"Zaman Aralığı",chartTitle:"Öngörülen Stok — İlk 5 Malzeme",dashedLinesROP:"Kesik çizgiler = yeniden sipariş noktaları",stockLevel:"Stok Seviyesi",reorderPoint:"Yeniden Sipariş Noktası",safetyMargin:"Güvenlik Marjı",trendLegend:"Trend (geçmiş tabanlı, %95 servis seviyesi)",estimatedLegend:"Tahmini (ağırlık farkı)",noDataLegend:"Veri yok",ropLabel:"ROP",ssLabel:"SS",safetyStockLegend:"Güvenlik stoku",stockArrivalLegend:"Stok varışı",stockoutLegend:"Stok tükenmesi",alertCount_one:"{{count}} uyarı",alertCount_other:"{{count}} uyarı",order:"Sipariş",save:"Kaydet",cancel:"İptal",settingsSaved:"Ayarlar kaydedildi",failedSaveSettings:"Ayarlar kaydedilemedi",globalLeadTimeSaved:"Global teslim süresi kaydedildi",globalLeadTime:"Global teslim süresi",globalLeadTimeHint:"Global teslim süresi tabanı — tüm SKU'lar için yeniden sipariş noktası hesaplamasında kullanılır",skuLeadTimeOverride:"SKU Teslim Süresi Geçersiz Kılma",skuLeadTimeHint:"0 = global teslim süresini kullan. Bu SKU için geçersiz kılmak için >0 ayarlayın.",safetyMarginLabel:"Güvenlik Marjı",effectiveLeadTime:"Etkili Teslim Süresi",effectiveLeadTimeHint:"max(global {{global}}g, SKU {{sku}}g)",reorderPointHint:"d̄ × LT + güvenlik marjı — stok bu seviyeye geldiğinde sipariş ver",safetyMarginHint:"İstatistiksel güvenlik stoku (z=1.65 × σ × √LT) + kullanıcı tanımlı tampon",safetyMarginHintDays:"İstatistiksel güvenlik stokunun üzerine eklenen tampon.{{approx}}",safetyMarginHintDaysApprox:" ≈ mevcut oranda {{g}}g.",safetyMarginHintG:"İstatistiksel güvenlik stokunun üzerine eklenen sabit ağırlık tamponu.{{approx}}",safetyMarginHintGApprox:" ≈ mevcut oranda {{days}}g.",individualSpools:"Bireysel makaralar",labelWeight:"Etiket",spoolCount_one:"{{count}} makara",spoolCount_other:"{{count}} makara",stockBreakRisk:"Stok tükenme riski",stockBreakDetail:"{{days}}g kaldı, teslim süresi {{lt}}g.",stockBreakBefore:"Yenileme öncesi stok tükenmesi",reorderNow:"Şimdi sipariş ver",reorderTriggerPassed:"Tetikleyici tarih {{date}} geçti.",shoppingList:"Alışveriş Listesi",shoppingListItems_one:"({{count}} öğe)",shoppingListItems_other:"({{count}} öğe)",shoppingListEmpty:"Alışveriş listesi boş. Öğe eklemek için herhangi bir satırdaki sepet simgesine tıklayın.",addToCart:"Alışveriş listesine ekle",alertsSnoozed:"Bu SKU için uyarıları sustur",alertsEnabled:"Bu SKU için uyarıları aç",addedToCart:"Alışveriş listesine eklendi",failedAddItem:"Öğe eklenemedi",listView:"Liste",logisticsView:"Lojistik",qty:"Adet",weight:"Ağırlık",leadTime:"Teslim Süresi",expectedRestock:"Beklenen Yenileme",status:"Durum",note:"Not",pending:"Beklemede",purchased:"Satın Alındı",received:"Teslim Alındı",markPurchased:"Satın alındı olarak işaretle",markReceived:"Teslim alındı olarak işaretle — makaraları Stok envanterine ekler",resetToPending:"Beklemeye sıfırla",remove:"Kaldır",clearAll:"Tümünü temizle",downloadCsv:"CSV",addToCartTitle:"Alışveriş Listesine Ekle",byQuantity:"Miktara Göre",byDuration:"Süreye Göre",numberOfSpools:"Makara sayısı",lastHowManyDays:"Kaç gün dayanmalı?",noUsageQty:"Kullanım verisi yok — miktar 1 olarak ayarlandı.",noteOptional:"Not (isteğe bağlı)",notePlaceholder:"örn. X projesi için, acil…",addNSpools_one:"{{count}} makara ekle",addNSpools_other:"{{count}} makara ekle",onArrival:"Varışta",stockBreakIn:"{{days}}g içinde stok tükenmesi.",stockRunsOutBefore:"{{lt}}g teslim süresi dolmadan önce stok tükenir.",atRate:"{{rate}}g/gün oranında size gerekli",moreSpools_one:"{{count}} daha makara",moreSpools_other:"{{count}} daha makara",bridgeGap:"boşluğu kapatmak için.",noReadAccess:"Envanter tahminlerini görüntüleme izniniz yok.",noWriteAccess:"Tahmin ayarlarını değiştirme izniniz yok."}},Epe={en:{translation:wpe},de:{translation:Spe},es:{translation:kpe},fr:{translation:_pe},ja:{translation:Ppe},it:{translation:Cpe},ko:{translation:Npe},"pt-BR":{translation:Tpe},"zh-CN":{translation:Ape},"zh-TW":{translation:jpe},tr:{translation:Mpe}};Ao.use(xZ).use(Jme).init({resources:Epe,fallbackLng:"en",supportedLngs:["en","de","es","fr","ja","it","ko","pt-BR","tr","zh-CN","zh-TW"],detection:{order:["localStorage","navigator","htmlTag"],lookupLocalStorage:"bambutrack_language",caches:["localStorage"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const d8=[{code:"en",name:"English",nativeName:"English"},{code:"de",name:"German",nativeName:"Deutsch"},{code:"es",name:"Spanish",nativeName:"Español"},{code:"fr",name:"French",nativeName:"Français"},{code:"ja",name:"Japanese",nativeName:"日本語"},{code:"it",name:"Italian",nativeName:"Italiano"},{code:"ko",name:"Korean",nativeName:"한국어"},{code:"pt-BR",name:"Portuguese (Brazil)",nativeName:"Português (Brasil)"},{code:"zh-CN",name:"Chinese (Simplified)",nativeName:"简体中文"},{code:"zh-TW",name:"Chinese (Traditional)",nativeName:"繁體中文"},{code:"tr",name:"Turkish",nativeName:"Türkçe"}];var u8="popstate";function Dpe(t={}){function e(n,i){let{pathname:s,search:o,hash:l}=n.location;return WL("",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||"default")}function a(n,i){return typeof i=="string"?i:Rw(i)}return Lpe(e,a,null,t)}function Bn(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function Nc(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function Fpe(){return Math.random().toString(36).substring(2,10)}function m8(t,e){return{usr:t.state,key:t.key,idx:e}}function WL(t,e,a=null,n){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?Qy(e):e,state:a,key:e&&e.key||n||Fpe()}}function Rw({pathname:t="/",search:e="",hash:a=""}){return e&&e!=="?"&&(t+=e.charAt(0)==="?"?e:"?"+e),a&&a!=="#"&&(t+=a.charAt(0)==="#"?a:"#"+a),t}function Qy(t){let e={};if(t){let a=t.indexOf("#");a>=0&&(e.hash=t.substring(a),t=t.substring(0,a));let n=t.indexOf("?");n>=0&&(e.search=t.substring(n),t=t.substring(0,n)),t&&(e.pathname=t)}return e}function Lpe(t,e,a,n={}){let{window:i=document.defaultView,v5Compat:s=!1}=n,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(),x=b==null?null:b-d;d=b,c&&c({action:l,location:v.location,delta:x})}function p(b,x){l="PUSH";let S=WL(v.location,b,x);d=u()+1;let k=m8(S,d),C=v.createHref(S);try{o.pushState(k,"",C)}catch(N){if(N instanceof DOMException&&N.name==="DataCloneError")throw N;i.location.assign(C)}s&&c&&c({action:l,location:v.location,delta:1})}function f(b,x){l="REPLACE";let S=WL(v.location,b,x);d=u();let k=m8(S,d),C=v.createHref(S);o.replaceState(k,"",C),s&&c&&c({action:l,location:v.location,delta:0})}function g(b){return Rpe(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(u8,m),c=b,()=>{i.removeEventListener(u8,m),c=null}},createHref(b){return e(i,b)},createURL:g,encodeLocation(b){let x=g(b);return{pathname:x.pathname,search:x.search,hash:x.hash}},push:p,replace:f,go(b){return o.go(b)}};return v}function Rpe(t,e=!1){let a="http://localhost";typeof window<"u"&&(a=window.location.origin!=="null"?window.location.origin:window.location.href),Bn(a,"No window.location.(origin|href) available to create URL");let n=typeof t=="string"?t:Rw(t);return n=n.replace(/ $/,"%20"),!e&&n.startsWith("//")&&(n=a+n),new URL(n,a)}function yZ(t,e,a="/"){return zpe(t,e,a,!1)}function zpe(t,e,a,n){let i=typeof e=="string"?Qy(e):e,s=Ym(i.pathname||"/",a);if(s==null)return null;let o=vZ(t);Ipe(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(n)&&c)return;Bn(u.relativePath.startsWith(n),`Absolute route path "${u.relativePath}" nested under path "${n}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(n.length)}let m=Im([n,u.relativePath]),p=a.concat(u);o.children&&o.children.length>0&&(Bn(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),vZ(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:Gpe(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of wZ(o.path))s(o,l,!0,c)}),e}function wZ(t){let e=t.split("/");if(e.length===0)return[];let[a,...n]=e,i=a.endsWith("?"),s=a.replace(/\?$/,"");if(n.length===0)return i?[s,""]:[s];let o=wZ(n.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 Ipe(t){t.sort((e,a)=>e.score!==a.score?a.score-e.score:$pe(e.routesMeta.map(n=>n.childrenIndex),a.routesMeta.map(n=>n.childrenIndex)))}var Ope=/^:[\w-]+$/,Bpe=3,Upe=2,Hpe=1,qpe=10,Vpe=-2,p8=t=>t==="*";function Gpe(t,e){let a=t.split("/"),n=a.length;return a.some(p8)&&(n+=Vpe),e&&(n+=Upe),a.filter(i=>!p8(i)).reduce((i,s)=>i+(Ope.test(s)?Bpe:s===""?Hpe:qpe),n)}function $pe(t,e){return t.length===e.length&&t.slice(0,-1).every((n,i)=>n===e[i])?t[t.length-1]-e[e.length-1]:0}function Wpe(t,e,a=!1){let{routesMeta:n}=t,i={},s="/",o=[];for(let l=0;l{if(u==="*"){let g=l[p]||"";o=s.slice(0,s.length-g.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 Kpe(t,e=!1,a=!0){Nc(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 n=[],i="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(n.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(n.push({paramName:"*"}),i+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?i+="\\/*$":t!==""&&t!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,e?void 0:"i"),n]}function Ype(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return Nc(!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 Ym(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let a=e.endsWith("/")?e.length-1:e.length,n=t.charAt(a);return n&&n!=="/"?null:t.slice(a)||"/"}var Qpe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function Xpe(t,e="/"){let{pathname:a,search:n="",hash:i=""}=typeof t=="string"?Qy(t):t,s;return a?(a=a.replace(/\/\/+/g,"/"),a.startsWith("/")?s=h8(a.substring(1),"/"):s=h8(a,e)):s=e,{pathname:s,search:ehe(n),hash:the(i)}}function h8(t,e){let a=e.replace(/\/+$/,"").split("/");return t.split("/").forEach(i=>{i===".."?a.length>1&&a.pop():i!=="."&&a.push(i)}),a.length>1?a.join("/"):"/"}function gE(t,e,a,n){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(n)}]. Please separate it out to the \`to.${a}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function Zpe(t){return t.filter((e,a)=>a===0||e.route.path&&e.route.path.length>0)}function vI(t){let e=Zpe(t);return e.map((a,n)=>n===e.length-1?a.pathname:a.pathnameBase)}function wI(t,e,a,n=!1){let i;typeof t=="string"?i=Qy(t):(i={...t},Bn(!i.pathname||!i.pathname.includes("?"),gE("?","pathname","search",i)),Bn(!i.pathname||!i.pathname.includes("#"),gE("#","pathname","hash",i)),Bn(!i.search||!i.search.includes("#"),gE("#","search","hash",i)));let s=t===""||i.pathname==="",o=s?"/":i.pathname,l;if(o==null)l=a;else{let m=e.length-1;if(!n&&o.startsWith("..")){let p=o.split("/");for(;p[0]==="..";)p.shift(),m-=1;i.pathname=p.join("/")}l=m>=0?e[m]:"/"}let c=Xpe(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&a.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var Im=t=>t.join("/").replace(/\/\/+/g,"/"),Jpe=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),ehe=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,the=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,ahe=class{constructor(t,e,a,n=!1){this.status=t,this.statusText=e||"",this.internal=n,a instanceof Error?(this.data=a.toString(),this.error=a):this.data=a}};function rhe(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function nhe(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var SZ=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function kZ(t,e){let a=t;if(typeof a!="string"||!Qpe.test(a))return{absoluteURL:void 0,isExternal:!1,to:a};let n=a,i=!1;if(SZ)try{let s=new URL(window.location.href),o=a.startsWith("//")?new URL(s.protocol+a):new URL(a),l=Ym(o.pathname,e);o.origin===s.origin&&l!=null?a=l+o.search+o.hash:i=!0}catch{Nc(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:n,isExternal:i,to:a}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var _Z=["POST","PUT","PATCH","DELETE"];new Set(_Z);var ihe=["GET",..._Z];new Set(ihe);var Xy=y.createContext(null);Xy.displayName="DataRouter";var TT=y.createContext(null);TT.displayName="DataRouterState";var she=y.createContext(!1),PZ=y.createContext({isTransitioning:!1});PZ.displayName="ViewTransition";var ohe=y.createContext(new Map);ohe.displayName="Fetchers";var lhe=y.createContext(null);lhe.displayName="Await";var Fl=y.createContext(null);Fl.displayName="Navigation";var MS=y.createContext(null);MS.displayName="Location";var Rc=y.createContext({outlet:null,matches:[],isDataRoute:!1});Rc.displayName="Route";var SI=y.createContext(null);SI.displayName="RouteError";var CZ="REACT_ROUTER_ERROR",che="REDIRECT",dhe="ROUTE_ERROR_RESPONSE";function uhe(t){if(t.startsWith(`${CZ}:${che}:{`))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 mhe(t){if(t.startsWith(`${CZ}:${dhe}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new ahe(e.status,e.statusText,e.data)}catch{}}function phe(t,{relative:e}={}){Bn(Zy(),"useHref() may be used only in the context of a component.");let{basename:a,navigator:n}=y.useContext(Fl),{hash:i,pathname:s,search:o}=DS(t,{relative:e}),l=s;return a!=="/"&&(l=s==="/"?a:Im([a,s])),n.createHref({pathname:l,search:o,hash:i})}function Zy(){return y.useContext(MS)!=null}function vd(){return Bn(Zy(),"useLocation() may be used only in the context of a component."),y.useContext(MS).location}var NZ="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function TZ(t){y.useContext(Fl).static||y.useLayoutEffect(t)}function Ii(){let{isDataRoute:t}=y.useContext(Rc);return t?Nhe():hhe()}function hhe(){Bn(Zy(),"useNavigate() may be used only in the context of a component.");let t=y.useContext(Xy),{basename:e,navigator:a}=y.useContext(Fl),{matches:n}=y.useContext(Rc),{pathname:i}=vd(),s=JSON.stringify(vI(n)),o=y.useRef(!1);return TZ(()=>{o.current=!0}),y.useCallback((c,d={})=>{if(Nc(o.current,NZ),!o.current)return;if(typeof c=="number"){a.go(c);return}let u=wI(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:Im([e,u.pathname])),(d.replace?a.replace:a.push)(u,d.state,d)},[e,a,s,i,t])}var AZ=y.createContext(null);function Jy(){return y.useContext(AZ)}function fhe(t){let e=y.useContext(Rc).outlet;return y.useMemo(()=>e&&y.createElement(AZ.Provider,{value:t},e),[e,t])}function ES(){let{matches:t}=y.useContext(Rc),e=t[t.length-1];return e?e.params:{}}function DS(t,{relative:e}={}){let{matches:a}=y.useContext(Rc),{pathname:n}=vd(),i=JSON.stringify(vI(a));return y.useMemo(()=>wI(t,JSON.parse(i),n,e==="path"),[t,i,n,e])}function ghe(t,e){return jZ(t,e)}function jZ(t,e,a,n,i){Bn(Zy(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=y.useContext(Fl),{matches:o}=y.useContext(Rc),l=o[o.length-1],c=l?l.params:{},d=l?l.pathname:"/",u=l?l.pathnameBase:"/",m=l&&l.route;{let S=m&&m.path||"";EZ(d,!m||S.endsWith("*")||S.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=vd(),f;if(e){let S=typeof e=="string"?Qy(e):e;Bn(u==="/"||S.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 "${S.pathname}" was given in the \`location\` prop.`),f=S}else f=p;let g=f.pathname||"/",v=g;if(u!=="/"){let S=u.replace(/^\//,"").split("/");v="/"+g.replace(/^\//,"").split("/").slice(S.length).join("/")}let b=yZ(t,{pathname:v});Nc(m||b!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),Nc(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 x=whe(b&&b.map(S=>Object.assign({},S,{params:Object.assign({},c,S.params),pathname:Im([u,s.encodeLocation?s.encodeLocation(S.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:S.pathname]),pathnameBase:S.pathnameBase==="/"?u:Im([u,s.encodeLocation?s.encodeLocation(S.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:S.pathnameBase])})),o,a,n,i);return e&&x?y.createElement(MS.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},x):x}function bhe(){let t=Che(),e=rhe(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),a=t instanceof Error?t.stack:null,n="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:n},s={padding:"2px 4px",backgroundColor:n},o=null;return console.error("Error handled by React Router default ErrorBoundary:",t),o=y.createElement(y.Fragment,null,y.createElement("p",null,"💿 Hey developer 👋"),y.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",y.createElement("code",{style:s},"ErrorBoundary")," or"," ",y.createElement("code",{style:s},"errorElement")," prop on your route.")),y.createElement(y.Fragment,null,y.createElement("h2",null,"Unexpected Application Error!"),y.createElement("h3",{style:{fontStyle:"italic"}},e),a?y.createElement("pre",{style:i},a):null,o)}var xhe=y.createElement(bhe,null),MZ=class extends y.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 a=mhe(t.digest);a&&(t=a)}let e=t!==void 0?y.createElement(Rc.Provider,{value:this.props.routeContext},y.createElement(SI.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?y.createElement(yhe,{error:t},e):e}};MZ.contextType=she;var bE=new WeakMap;function yhe({children:t,error:e}){let{basename:a}=y.useContext(Fl);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let n=uhe(e.digest);if(n){let i=bE.get(e);if(i)throw i;let s=kZ(n.location,a);if(SZ&&!bE.get(e))if(s.isExternal||n.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:n.replace}));throw bE.set(e,o),o}return y.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function vhe({routeContext:t,match:e,children:a}){let n=y.useContext(Xy);return n&&n.static&&n.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(n.staticContext._deepestRenderedBoundaryId=e.route.id),y.createElement(Rc.Provider,{value:t},a)}function whe(t,e=[],a=null,n=null,i=null){if(t==null){if(!a)return null;if(a.errors)t=a.matches;else if(e.length===0&&!a.initialized&&a.matches.length>0)t=a.matches;else return null}let s=t,o=a?.errors;if(o!=null){let u=s.findIndex(m=>m.route.id&&o?.[m.route.id]!==void 0);Bn(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(a)for(let u=0;u=0?s=s.slice(0,c+1):s=[s[0]];break}}}let d=a&&n?(u,m)=>{n(u,{location:a.location,params:a.matches?.[0]?.params??{},unstable_pattern:nhe(a.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,g=!1,v=null,b=null;a&&(f=o&&m.route.id?o[m.route.id]:void 0,v=m.route.errorElement||xhe,l&&(c<0&&p===0?(EZ("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),g=!0,b=null):c===p&&(g=!0,b=m.route.hydrateFallbackElement||null)));let x=e.concat(s.slice(0,p+1)),S=()=>{let k;return f?k=v:g?k=b:m.route.Component?k=y.createElement(m.route.Component,null):m.route.element?k=m.route.element:k=u,y.createElement(vhe,{match:m,routeContext:{outlet:u,matches:x,isDataRoute:a!=null},children:k})};return a&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?y.createElement(MZ,{location:a.location,revalidation:a.revalidation,component:v,error:f,children:S(),routeContext:{outlet:null,matches:x,isDataRoute:!0},onError:d}):S()},null)}function kI(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function She(t){let e=y.useContext(Xy);return Bn(e,kI(t)),e}function khe(t){let e=y.useContext(TT);return Bn(e,kI(t)),e}function _he(t){let e=y.useContext(Rc);return Bn(e,kI(t)),e}function _I(t){let e=_he(t),a=e.matches[e.matches.length-1];return Bn(a.route.id,`${t} can only be used on routes that contain a unique "id"`),a.route.id}function Phe(){return _I("useRouteId")}function Che(){let t=y.useContext(SI),e=khe("useRouteError"),a=_I("useRouteError");return t!==void 0?t:e.errors?.[a]}function Nhe(){let{router:t}=She("useNavigate"),e=_I("useNavigate"),a=y.useRef(!1);return TZ(()=>{a.current=!0}),y.useCallback(async(i,s={})=>{Nc(a.current,NZ),a.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var f8={};function EZ(t,e,a){!e&&!f8[t]&&(f8[t]=!0,Nc(!1,a))}y.memo(The);function The({routes:t,future:e,state:a,onError:n}){return jZ(t,void 0,a,n,e)}function wg({to:t,replace:e,state:a,relative:n}){Bn(Zy()," may be used only in the context of a component.");let{static:i}=y.useContext(Fl);Nc(!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}=y.useContext(Rc),{pathname:o}=vd(),l=Ii(),c=wI(t,vI(s),o,n==="path"),d=JSON.stringify(c);return y.useEffect(()=>{l(JSON.parse(d),{replace:e,state:a,relative:n})},[l,d,n,e,a]),null}function DZ(t){return fhe(t.context)}function jr(t){Bn(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Ahe({basename:t="/",children:e=null,location:a,navigationType:n="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){Bn(!Zy(),"You cannot render a inside another . You should never have more than one in your app.");let l=t.replace(/^\/*/,"/"),c=y.useMemo(()=>({basename:l,navigator:i,static:s,unstable_useTransitions:o,future:{}}),[l,i,s,o]);typeof a=="string"&&(a=Qy(a));let{pathname:d="/",search:u="",hash:m="",state:p=null,key:f="default"}=a,g=y.useMemo(()=>{let v=Ym(d,l);return v==null?null:{location:{pathname:v,search:u,hash:m,state:p,key:f},navigationType:n}},[l,d,u,m,p,f,n]);return Nc(g!=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.`),g==null?null:y.createElement(Fl.Provider,{value:c},y.createElement(MS.Provider,{children:e,value:g}))}function jhe({children:t,location:e}){return ghe(KL(t),e)}function KL(t,e=[]){let a=[];return y.Children.forEach(t,(n,i)=>{if(!y.isValidElement(n))return;let s=[...e,i];if(n.type===y.Fragment){a.push.apply(a,KL(n.props.children,s));return}Bn(n.type===jr,`[${typeof n.type=="string"?n.type:n.type.name}] is not a component. All component children of must be a or `),Bn(!n.props.index||!n.props.children,"An index route cannot have child routes.");let o={id:n.props.id||s.join("-"),caseSensitive:n.props.caseSensitive,element:n.props.element,Component:n.props.Component,index:n.props.index,path:n.props.path,middleware:n.props.middleware,loader:n.props.loader,action:n.props.action,hydrateFallbackElement:n.props.hydrateFallbackElement,HydrateFallback:n.props.HydrateFallback,errorElement:n.props.errorElement,ErrorBoundary:n.props.ErrorBoundary,hasErrorBoundary:n.props.hasErrorBoundary===!0||n.props.ErrorBoundary!=null||n.props.errorElement!=null,shouldRevalidate:n.props.shouldRevalidate,handle:n.props.handle,lazy:n.props.lazy};n.props.children&&(o.children=KL(n.props.children,s)),a.push(o)}),a}var CC="get",NC="application/x-www-form-urlencoded";function AT(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Mhe(t){return AT(t)&&t.tagName.toLowerCase()==="button"}function Ehe(t){return AT(t)&&t.tagName.toLowerCase()==="form"}function Dhe(t){return AT(t)&&t.tagName.toLowerCase()==="input"}function Fhe(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Lhe(t,e){return t.button===0&&(!e||e==="_self")&&!Fhe(t)}function YL(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,a)=>{let n=t[a];return e.concat(Array.isArray(n)?n.map(i=>[a,i]):[[a,n]])},[]))}function Rhe(t,e){let a=YL(t);return e&&e.forEach((n,i)=>{a.has(i)||e.getAll(i).forEach(s=>{a.append(i,s)})}),a}var F_=null;function zhe(){if(F_===null)try{new FormData(document.createElement("form"),0),F_=!1}catch{F_=!0}return F_}var Ihe=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function xE(t){return t!=null&&!Ihe.has(t)?(Nc(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${NC}"`),null):t}function Ohe(t,e){let a,n,i,s,o;if(Ehe(t)){let l=t.getAttribute("action");n=l?Ym(l,e):null,a=t.getAttribute("method")||CC,i=xE(t.getAttribute("enctype"))||NC,s=new FormData(t)}else if(Mhe(t)||Dhe(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a