mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
* chore(release): open v3.8.23 development cycle
* fix(anthropic): strip top_p when temperature is set to avoid 400 (#3691)
Integrated into release/v3.8.23
* fix(vertex): support Vertex AI Express-mode API keys (#3690)
Integrated into release/v3.8.23
* fix(stream): error on empty Claude SSE instead of synthetic success (#3689)
Integrated into release/v3.8.23
* fix(oauth): stop token-refresh invalidation loop + harden proxy resolution (#3692)
Integrated into release/v3.8.23
* docs: add FUNDING.yml and Support section to README (#3698)
Integrated into release/v3.8.23
* feat: gemini - handle known ratelimits (#3686)
Integrated into release/v3.8.23
* fix: stream combo fails over on empty content-filtered response (#3685) (#3702)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(antigravity): preserve gemini-3.1-pro high/low budget tiers (#3696) (#3703)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(auto-combo): add auto-updating model intelligence scoring (#3660)
Integrated into release/v3.8.23
* fix(gemini): context-mode fallback for signatureless tool calls (#3688) (#3704)
* chore(quality-gate): reconcile file-size baseline (27 files + providerLimits.ts) (#3705)
* feat(vertex): dynamic model discovery via Generative Language models API (#3712)
Integrated into release/v3.8.23. Vertex dynamic model discovery — surfaces image models (imagen-*, gemini-*-image), embeddings and audio from the live Generative Language catalog, with cached→static fallback and the shared parseGeminiModelsList helper. Validated: parser test 5/5, typecheck:core clean.
* fix(combo): gate reasoning token buffer (#3700)
Integrated into release/v3.8.23. Makes the #3588 reasoning token buffer safe and configurable: only inflates max_tokens when the model has a known, non-default output cap and the buffered value fits inside it; otherwise preserves/clamps the client limit. Adds the reasoningTokenBufferEnabled kill switch (default ON). Validated: combo-routing-engine 81/81, combo-config 25/25, combo-quality-validator-reasoning 12/12, phase1f 10/10, typecheck:core clean.
* refactor(#3501): god-component Phase 1g-1j — client 4062→3408 LOC (-654) (#3717)
Phase 1g-1j of #3501: client 4062→3408 LOC. Pure extraction (ProviderPlaygroundPanel, useCommandCodeAuth, useExternalLinkFlow+ExternalLinkModal, useAuthFileHandlers) + loadConnProxies ReferenceError fix + phase1f test path fix.
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* refactor(#3501): god-component Phase 1k-1m — client 3408→2553 LOC (-855) (#3721)
Phase 1k-1m of #3501: client 3408→2553 LOC. Pure extraction (useModelImportHandlers+ImportProgressModal, useModelVisibilityHandlers, ProviderModelsSection).
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* docs(changelog): restore #3590 bullet lost on the v3.8.20 release branch
The fix itself reached main pre-tag via cherry-pick #3591, but its changelog
bullet (commit e33fdd4ab) only ever existed on release/v3.8.20 after the
squash-merge. Restored under [3.8.20] per the 2026-06-12 release-branch
leftover audit (_tasks/release-audit/release-leftovers-audit-2026-06-12.md).
* fix(kiro): resolve quota for IAM Identity Center accounts missing a profileArn (#3722)
Integrated into release/v3.8.23
* refactor(#3501): god-component Phase 1n-1s — client 2553→1376 LOC (-1177) (#3725)
Phase 1n-1s of #3501: client 2553→1376 LOC. Pure extraction (ConnectionsListPanel, ConnectionsHeaderToolbar, ZedImportCard, BatchTestResultsModal, AdaptaTutorialModal, useApiKeySave + helpers).
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* feat(model-lockout): settings UI, backend integration, error classification, and success-decay recovery (#3629)
Integrated into release/v3.8.23
* refactor(#3501): god-component Phase 1t — client 1376→781 LOC (≤800 TARGET REACHED ✅) (#3727)
Phase 1t of #3501: client 1376→781 LOC (≤800 reached). Original god-component 12,882→781 (−94%).
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* fix: bundle @omniroute/opencode-plugin inside omniroute + add 'setup opencode' CLI command (#3726)
Integrated into release/v3.8.23
* feat(vertex): self-tracked USD spend since account added (#3724)
Integrated into release/v3.8.23
* fix(qwen-web): migrate to v2 chat API with full cookie-jar replay (#3288) (#3723)
Integrated into release/v3.8.23
* fix(sse): make safeLogEvents async — 'await' in a sync function broke every chatHelpers import
#3692 added a lazy 'await import(proxyEgress)' for egress-IP visibility inside
safeLogEvents, which is a sync function — an ES syntax error. It went unnoticed
because typecheck:core does not cover src/sse and no test in the merge gates
loaded chatHelpers via tsx; any consumer that did (chat-context-relay and
chat-route-coverage suites, integration harnesses) failed at module load with
'await can only be used inside an async function'.
safeLogEvents is fire-and-forget logging with an outer try/catch, so making it
async (and 'void'-ing the single chat.ts call site) preserves behavior exactly.
Validation: tests/unit/chat-context-relay.test.ts + chat-route-coverage.test.ts
went from failing-at-load to green (+14 tests destravados).
* fix(sse): remove cross-provider credential leak in emergency fallback + combo/proxy audit fixes (#3699)
Integrated into release/v3.8.23
* fix(executors): inject MiMoCode anti-abuse marker so free endpoint stops 403ing (#3728)
Integrated into release/v3.8.23
* fix(dashboard): repair "Test all models" — toast crash, status icons, auto-hide (#3729)
Integrated into release/v3.8.23
* chore(deps): bump actions/upload-artifact from 4 to 7 (#3735)
Integrated into release/v3.8.23 — aligns upload-artifact to v7 (already used across ci.yml).
* chore(deps): bump actions/cache from 4 to 5 (#3734)
Integrated into release/v3.8.23 — actions/cache v4→v5.
* chore(deps): bump actions/download-artifact from 4 to 8 (#3733)
Integrated into release/v3.8.23 — download-artifact v4→v8.
* feat(fallback): add OMNIROUTE_EMERGENCY_FALLBACK env switch (#3741)
Adds an OMNIROUTE_EMERGENCY_FALLBACK env switch to disable the emergency budget-exhaustion fallback (reroute to free nvidia/gpt-oss-120b). Default unchanged (enabled). Closes #3739, related #2879.
Integrated into release/v3.8.23.
* i18n: comprehensive zh-CN translation improvements (#3736)
Aligns zh-CN to en (hundreds of entries), translates batch-action labels + settings sidebar menu, adds categoryConfig/endpointTokenSaver keys, resolves __MISSING__ stubs. Sidebar/SidebarTab hardcoded strings replaced with t(). en.json purely additive (8 new sidebar.* keys, 0 removed); cli-i18n gate green.
Integrated into release/v3.8.23.
* chore(release): v3.8.23 — 2026-06-12
- CHANGELOG: complete v3.8.23 section (28 bullets, 27 commits)
- fix(webdav): resolve promise on writeStream finish, not req end — eliminates
intermittent 500 on PUT update (writeStream may not have flushed at rename time)
- test(autoCombo): stub DB calls from PR #3660 in tieredRotation.test.ts to prevent
5s timeout in vitest (getModelIntelligenceBySource DB init path)
- chore(env-sync): add XDG_DATA_HOME + OMNIROUTE_OPENCODE_PLUGIN_DIR to IGNORE_FROM_CODE
allowlist (introduced by PR #3726 setup-open-code.mjs, not OmniRoute config vars)
- chore(cli): regenerated bin/cli/api-commands/*.mjs (7 new, 27 updated)
* fix(model-family): fallback lookup also tries bare model name with dots
getNextFamilyFallback normalized dots-to-hyphens ("gemini-3.1-pro-high" →
"gemini-3-1-pro-high") but MODEL_FAMILIES keys use the literal dot form. The
lookup always missed, returning null for any model whose dots are part of the
name rather than a version separator.
Fallback: try MODEL_FAMILIES[lookupKey] ?? MODEL_FAMILIES[bareModel] so both
naming conventions are covered. Fixes T30 test (pre-existing since v3.8.22).
* feat: expose API key cost drilldown + quota % used (#3742)
Adds all-time USD cost per API key in the API Key Manager, a per-key deep-link into the Cost Explorer (filtered + grouped by model), URL-param hydration of range/groupBy/apiKeyIds, and a '% used' quota display. Review adjustments: extracted URL-param parsers to a tested module (Rule #18), i18n'd the new strings (en + zh-CN), dropped the redundant webdav-handler entry already on release.
Integrated into release/v3.8.23.
* feat: add provider display modes — All / Configured / Compact (#3743)
Replaces the Providers page configured-only toggle with All/Configured/Compact display modes (Compact = flat deduped grid, no-auth last). Persists the preference and migrates the legacy localStorage key. Rebased onto release/v3.8.23.
Integrated into release/v3.8.23.
* fix(cache): scope semantic-cache signature to API key (#3740)
Adds the api_key_id dimension to generateSignature's SHA-256 hash so two callers with different API keys never receive each other's cached responses. Threads apiKeyId through checkSemanticCache + both write sites; migration 098 clears pre-existing key-less entries; unauthenticated requests stay isolated from keyed ones. 3 TDD tests.
Integrated into release/v3.8.23.
* fix(responses): apply OpenAI Responses API stream=false spec default (#3708)
resolveStreamFlag now applies the stream=false-when-omitted default for sourceFormat=openai-responses (same as the existing claude path), so spec-compliant /v1/responses upstreams that return JSON no longer fall through to the wildcard-Accept heuristic and trigger STREAM_EARLY_EOF / 502. Codex CLI (stream:true) and explicit text/event-stream clients unaffected.
Integrated into release/v3.8.23.
* chore(release): reconcile CI gates for v3.8.23
- file-size baseline: re-freeze 8 files grown by PRs #3742/#3743/#3740
(cost drilldown, provider display modes, cache key isolation)
- ARCHITECTURE.md: update executor count 55→60 (check:docs-counts drift)
- .env.example: add OMNIROUTE_EMERGENCY_FALLBACK (#3741, env-doc-sync)
- CHANGELOG: add formatted bullets for #3742, #3743, #3708, #3740,
model-family-fallback fix; remove duplicate raw ### Fixed section
* test: restore assert count to satisfy check:test-masking gate
Three test files had net assertion removals after behavior-changing PRs:
- chatcore-translation-paths: emergency fallback moved to routing layer
(#3699) — add body error assertion + model-name guard
- executor-vertex-extended: non-JSON is now Express API key (#3690) —
add projects/-path guard to the express-key URL test
- stream-utils: empty streams now emit error (#3685) — add code/message/
status/completePayload guards to both passthrough and translate variants
All new assertions are meaningful (code enum value, 5xx range, non-empty
message, onComplete must-not-fire contract).
* fix(ci): move rtl-logical-classes test to ui/ so vitest:ui runner collects it
---------
Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
Co-authored-by: NOXX - Commiter <artur1992123@mail.ru>
Co-authored-by: Nick Sullivan <142708+TechNickAI@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Chewji <126886556+Chewji9875@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: Felipe Sartori <felipesartori.ti@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zois Pagoulatos <zpagoulatos@hotmail.com>
Co-authored-by: sdfsdfw2 <167810361+sdfsdfw2@users.noreply.github.com>
Co-authored-by: Witroch4 <witalo_rocha@hotmail.com>
693 lines
25 KiB
TypeScript
693 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { cn } from "@/shared/utils/cn";
|
|
import { getActiveSidebarHref } from "@/shared/utils/sidebarRouteMatch";
|
|
import { APP_CONFIG } from "@/shared/constants/appConfig";
|
|
import OmniRouteLogo from "./OmniRouteLogo";
|
|
import Button from "./Button";
|
|
import { ConfirmModal } from "./Modal";
|
|
import CloudSyncStatus from "./CloudSyncStatus";
|
|
import { useTranslations } from "next-intl";
|
|
import {
|
|
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
|
|
SIDEBAR_SETTINGS_UPDATED_EVENT,
|
|
SIDEBAR_SECTION_ORDER_KEY,
|
|
SIDEBAR_ITEM_ORDER_KEY,
|
|
SIDEBAR_SECTIONS,
|
|
getSectionItems,
|
|
normalizeHiddenSidebarItems,
|
|
applySectionOrder,
|
|
applyItemOrder,
|
|
type SidebarSectionId,
|
|
type SidebarItemDefinition,
|
|
type SidebarItemGroup,
|
|
type SidebarItemOrder,
|
|
} from "@/shared/constants/sidebarVisibility";
|
|
|
|
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
|
|
const DEFAULT_EXPANDED: SidebarSectionId = "omni-proxy";
|
|
const EXPANDED_SECTIONS_KEY = "sidebar-expanded-sections";
|
|
const PINNED_SECTIONS_KEY = "sidebar-pinned-sections";
|
|
|
|
type SidebarProps = {
|
|
onClose?: () => void;
|
|
collapsed?: boolean;
|
|
onToggleCollapse?: () => void;
|
|
isMacElectron?: boolean;
|
|
};
|
|
|
|
type HoveredItem = { id: string; label: string; x: number; y: number } | null;
|
|
|
|
function loadFromStorage<T>(key: string, fallback: T): T {
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed)) return parsed as T;
|
|
}
|
|
} catch {}
|
|
return fallback;
|
|
}
|
|
|
|
function saveToStorage(key: string, value: unknown) {
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
} catch {}
|
|
}
|
|
|
|
export default function Sidebar({
|
|
onClose,
|
|
collapsed = false,
|
|
onToggleCollapse,
|
|
isMacElectron = false,
|
|
}: SidebarProps) {
|
|
const pathname = usePathname();
|
|
const t = useTranslations("sidebar");
|
|
const tc = useTranslations("common");
|
|
const sidebarRef = useRef<HTMLElement>(null);
|
|
const [showShutdownModal, setShowShutdownModal] = useState(false);
|
|
const [showRestartModal, setShowRestartModal] = useState(false);
|
|
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
|
const [isRestarting, setIsRestarting] = useState(false);
|
|
const [isDisconnected, setIsDisconnected] = useState(false);
|
|
const [showDebug, setShowDebug] = useState(false);
|
|
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<string[]>([]);
|
|
const [sidebarSectionOrder, setSidebarSectionOrder] = useState<SidebarSectionId[]>([]);
|
|
const [sidebarItemOrder, setSidebarItemOrder] = useState<SidebarItemOrder>({});
|
|
const [customAppName, setCustomAppName] = useState<string | null>(null);
|
|
const [customLogo, setCustomLogo] = useState<string | null>(null);
|
|
const [expandedSections, setExpandedSections] = useState<Set<SidebarSectionId>>(
|
|
new Set([DEFAULT_EXPANDED])
|
|
);
|
|
const [pinnedSections, setPinnedSections] = useState<Set<SidebarSectionId>>(new Set());
|
|
const [hoveredItem, setHoveredItem] = useState<HoveredItem>(null);
|
|
|
|
// Load persisted state on mount; OmniProxy is pinned by default on first visit
|
|
useEffect(() => {
|
|
const storedExpanded = loadFromStorage<SidebarSectionId[]>(EXPANDED_SECTIONS_KEY, [
|
|
DEFAULT_EXPANDED,
|
|
]);
|
|
const pinnedRaw = (() => {
|
|
try {
|
|
return localStorage.getItem(PINNED_SECTIONS_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const storedPinned: SidebarSectionId[] =
|
|
pinnedRaw !== null
|
|
? (JSON.parse(pinnedRaw) as SidebarSectionId[])
|
|
: (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]);
|
|
|
|
const initialExpanded = new Set<SidebarSectionId>(
|
|
storedExpanded.length > 0 ? storedExpanded : [DEFAULT_EXPANDED]
|
|
);
|
|
const initialPinned = new Set<SidebarSectionId>(storedPinned);
|
|
// Pinned sections must also be expanded
|
|
for (const id of initialPinned) initialExpanded.add(id);
|
|
|
|
setExpandedSections(initialExpanded);
|
|
setPinnedSections(initialPinned);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const applySettings = (data) => {
|
|
setShowDebug(data?.debugMode === true);
|
|
setHiddenSidebarItems(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]));
|
|
setCustomAppName(data?.instanceName || null);
|
|
setCustomLogo(data?.customLogoBase64 || data?.customLogoUrl || null);
|
|
};
|
|
|
|
fetch("/api/settings")
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
applySettings(data);
|
|
if (Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY])) {
|
|
setSidebarSectionOrder(data[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]);
|
|
}
|
|
if (data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object") {
|
|
setSidebarItemOrder(data[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
|
|
const handleSettingsUpdated = (event: Event) => {
|
|
const detail = (event as CustomEvent<Record<string, unknown>>).detail || {};
|
|
if ("debugMode" in detail) setShowDebug(detail.debugMode === true);
|
|
if (HIDDEN_SIDEBAR_ITEMS_SETTING_KEY in detail) {
|
|
setHiddenSidebarItems(
|
|
normalizeHiddenSidebarItems(detail[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
|
|
);
|
|
}
|
|
if (SIDEBAR_SECTION_ORDER_KEY in detail && Array.isArray(detail[SIDEBAR_SECTION_ORDER_KEY])) {
|
|
setSidebarSectionOrder(detail[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]);
|
|
}
|
|
if (
|
|
SIDEBAR_ITEM_ORDER_KEY in detail &&
|
|
detail[SIDEBAR_ITEM_ORDER_KEY] &&
|
|
typeof detail[SIDEBAR_ITEM_ORDER_KEY] === "object"
|
|
) {
|
|
setSidebarItemOrder(detail[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder);
|
|
}
|
|
if ("instanceName" in detail) setCustomAppName((detail.instanceName as string) || null);
|
|
if ("customLogoBase64" in detail) {
|
|
setCustomLogo((detail.customLogoBase64 as string) || null);
|
|
} else if ("customLogoUrl" in detail) {
|
|
setCustomLogo((detail.customLogoUrl as string) || null);
|
|
}
|
|
};
|
|
|
|
window.addEventListener(SIDEBAR_SETTINGS_UPDATED_EVENT, handleSettingsUpdated as EventListener);
|
|
return () =>
|
|
window.removeEventListener(
|
|
SIDEBAR_SETTINGS_UPDATED_EVENT,
|
|
handleSettingsUpdated as EventListener
|
|
);
|
|
}, []);
|
|
|
|
const getSidebarLabel = (key: string, fallback: string) =>
|
|
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
|
|
|
|
const resolveItem = (item: SidebarItemDefinition, hidden: Set<string>) => {
|
|
if (hidden.has(item.id)) return null;
|
|
const subtitle = item.subtitleKey ? getSidebarLabel(item.subtitleKey, "") : undefined;
|
|
return {
|
|
...item,
|
|
label: getSidebarLabel(item.i18nKey, item.id),
|
|
subtitle: subtitle || undefined,
|
|
};
|
|
};
|
|
|
|
const hiddenSidebarSet = new Set(hiddenSidebarItems);
|
|
|
|
const orderedSections = applySectionOrder(
|
|
SIDEBAR_SECTIONS.filter((section) => section.visibility !== "debug" || showDebug),
|
|
sidebarSectionOrder
|
|
);
|
|
|
|
const visibleSections = orderedSections
|
|
.map((section) => {
|
|
const orderedChildren = applyItemOrder(
|
|
section.children,
|
|
sidebarItemOrder[section.id as SidebarSectionId] ?? []
|
|
);
|
|
|
|
const children = orderedChildren
|
|
.map((child) => {
|
|
if ("type" in child && child.type === "group") {
|
|
const items = child.items
|
|
.map((item) => resolveItem(item, hiddenSidebarSet))
|
|
.filter(Boolean) as (SidebarItemDefinition & { label: string })[];
|
|
if (items.length === 0) return null;
|
|
// Smart-grouping: single visible item → inline flat (no group header)
|
|
if (items.length === 1) return items[0];
|
|
return {
|
|
...child,
|
|
title: getSidebarLabel(child.titleKey, child.titleFallback),
|
|
items,
|
|
} as SidebarItemGroup & {
|
|
title: string;
|
|
items: (SidebarItemDefinition & { label: string })[];
|
|
};
|
|
}
|
|
return resolveItem(child as SidebarItemDefinition, hiddenSidebarSet);
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
...section,
|
|
title: getSidebarLabel(section.titleKey, section.titleFallback),
|
|
children,
|
|
};
|
|
})
|
|
.filter((section) => {
|
|
const allItems = section.children.flatMap((child: any) =>
|
|
child.type === "group" ? child.items : [child]
|
|
);
|
|
return allItems.length > 0;
|
|
});
|
|
|
|
const allVisibleItems = visibleSections.flatMap((section) =>
|
|
section.children.flatMap((child: any) => (child.type === "group" ? child.items : [child]))
|
|
);
|
|
|
|
const activeHref = getActiveSidebarHref(pathname, allVisibleItems);
|
|
|
|
// Auto-expand the section containing the active page (without closing others)
|
|
useEffect(() => {
|
|
if (collapsed) return;
|
|
for (const section of visibleSections) {
|
|
const sectionItems = section.children.flatMap((child: any) =>
|
|
child.type === "group" ? child.items : [child]
|
|
);
|
|
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
|
|
setExpandedSections((prev) => {
|
|
if (prev.has(section.id as SidebarSectionId)) return prev;
|
|
const next = new Set(prev);
|
|
next.add(section.id as SidebarSectionId);
|
|
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
|
|
return next;
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeHref, collapsed]);
|
|
|
|
// Accordion toggle: opening a section closes all non-pinned sections
|
|
const toggleSection = useCallback(
|
|
(sectionId: SidebarSectionId) => {
|
|
setExpandedSections((prev) => {
|
|
const isOpen = prev.has(sectionId);
|
|
let next: Set<SidebarSectionId>;
|
|
if (isOpen) {
|
|
// Close this section
|
|
next = new Set(prev);
|
|
next.delete(sectionId);
|
|
} else {
|
|
// Accordion: keep only pinned sections + the new one
|
|
next = new Set<SidebarSectionId>();
|
|
for (const id of pinnedSections) {
|
|
next.add(id);
|
|
}
|
|
next.add(sectionId);
|
|
}
|
|
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
|
|
return next;
|
|
});
|
|
},
|
|
[pinnedSections]
|
|
);
|
|
|
|
const togglePin = useCallback((sectionId: SidebarSectionId) => {
|
|
setPinnedSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(sectionId)) {
|
|
next.delete(sectionId);
|
|
} else {
|
|
next.add(sectionId);
|
|
// Ensure the section is expanded when pinned
|
|
setExpandedSections((prevExp) => {
|
|
if (prevExp.has(sectionId)) return prevExp;
|
|
const nextExp = new Set(prevExp);
|
|
nextExp.add(sectionId);
|
|
saveToStorage(EXPANDED_SECTIONS_KEY, [...nextExp]);
|
|
return nextExp;
|
|
});
|
|
}
|
|
saveToStorage(PINNED_SECTIONS_KEY, [...next]);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleShutdown = async () => {
|
|
setIsShuttingDown(true);
|
|
try {
|
|
await fetch("/api/shutdown", { method: "POST" });
|
|
} catch (e) {
|
|
// Expected to fail as server shuts down
|
|
}
|
|
setIsShuttingDown(false);
|
|
setShowShutdownModal(false);
|
|
setIsDisconnected(true);
|
|
};
|
|
|
|
const handleRestart = async () => {
|
|
setIsRestarting(true);
|
|
try {
|
|
await fetch("/api/restart", { method: "POST" });
|
|
} catch (e) {
|
|
// Expected to fail as server restarts
|
|
}
|
|
setIsRestarting(false);
|
|
setShowRestartModal(false);
|
|
setIsDisconnected(true);
|
|
setTimeout(() => globalThis.location.reload(), 3000);
|
|
};
|
|
|
|
const handleMouseEnter = useCallback(
|
|
(e: React.MouseEvent<HTMLElement>, id: string, label: string) => {
|
|
if (!collapsed) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const sidebarRect = sidebarRef.current?.getBoundingClientRect();
|
|
setHoveredItem({
|
|
id,
|
|
label,
|
|
x: (sidebarRect?.right ?? 64) + 8,
|
|
y: rect.top + rect.height / 2,
|
|
});
|
|
},
|
|
[collapsed]
|
|
);
|
|
|
|
const handleMouseLeave = useCallback(() => setHoveredItem(null), []);
|
|
|
|
const renderNavLink = (item) => {
|
|
const active = !item.external && activeHref === item.href;
|
|
const className = cn(
|
|
"flex items-center gap-3 rounded-lg transition-all group",
|
|
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-1.5",
|
|
active
|
|
? "bg-primary/10 text-primary"
|
|
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
|
);
|
|
const iconClassName = cn(
|
|
"material-symbols-outlined text-[18px] shrink-0",
|
|
active ? "fill-1" : "group-hover:text-primary transition-colors"
|
|
);
|
|
const content = (
|
|
<>
|
|
<span className={iconClassName}>{item.icon}</span>
|
|
{!collapsed && (
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium">{item.label}</span>
|
|
{item.subtitle && (
|
|
<span className="truncate text-[10px] text-text-muted/60">{item.subtitle}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
const sharedProps = {
|
|
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => handleMouseEnter(e, item.id, item.label),
|
|
onMouseLeave: handleMouseLeave,
|
|
};
|
|
|
|
if (item.external) {
|
|
return (
|
|
<a
|
|
key={item.href}
|
|
href={item.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={onClose}
|
|
className={className}
|
|
{...sharedProps}
|
|
>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={onClose}
|
|
className={className}
|
|
{...sharedProps}
|
|
>
|
|
{content}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
ref={sidebarRef}
|
|
className={cn(
|
|
"flex h-full min-h-0 flex-col border-r border-black/5 bg-sidebar transition-all duration-300 ease-in-out dark:border-white/5",
|
|
collapsed ? "w-16" : "w-[220px]"
|
|
)}
|
|
style={{ paddingTop: isMacElectron ? "var(--desktop-safe-top)" : undefined }}
|
|
>
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-3 focus:bg-primary focus:text-white focus:rounded-md focus:m-2"
|
|
>
|
|
{t("skipToContent")}
|
|
</a>
|
|
|
|
{(onToggleCollapse || !isMacElectron) && (
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 pb-2",
|
|
isMacElectron ? "pt-3" : "pt-5",
|
|
collapsed ? "px-3 justify-center" : "px-4"
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
{!isMacElectron && (
|
|
<>
|
|
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
|
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
|
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
|
</>
|
|
)}
|
|
{!collapsed && <div className="flex-1" />}
|
|
{onToggleCollapse && (
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
title={collapsed ? t("expandSidebar") : t("collapseSidebar")}
|
|
aria-expanded={!collapsed}
|
|
aria-label={collapsed ? t("expandSidebar") : t("collapseSidebar")}
|
|
className={cn(
|
|
"rounded-md p-1 text-text-muted/50 transition-colors hover:bg-black/5 hover:text-text-muted dark:hover:bg-white/5",
|
|
collapsed && !isMacElectron && "mt-2",
|
|
isMacElectron && "ms-auto"
|
|
)}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
|
{collapsed ? "chevron_right" : "chevron_left"}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={cn("py-3", collapsed ? "px-2" : "px-4")}>
|
|
<Link
|
|
href="/home"
|
|
className={cn("flex items-center", collapsed ? "justify-center" : "gap-2.5")}
|
|
>
|
|
<div className="flex items-center justify-center size-8 rounded bg-linear-to-br from-[#E54D5E] to-[#C93D4E] shrink-0">
|
|
{customLogo ? (
|
|
<img
|
|
src={customLogo}
|
|
alt={customAppName || APP_CONFIG.name}
|
|
className="size-5 object-contain"
|
|
/>
|
|
) : (
|
|
<OmniRouteLogo size={18} className="text-white" />
|
|
)}
|
|
</div>
|
|
{!collapsed && (
|
|
<div className="flex flex-col min-w-0">
|
|
<h1 className="text-sm font-semibold tracking-tight text-text-main truncate">
|
|
{customAppName || APP_CONFIG.name}
|
|
</h1>
|
|
<span className="text-[10px] text-text-muted">v{APP_CONFIG.version}</span>
|
|
</div>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
|
|
<nav
|
|
aria-label="Main navigation"
|
|
className={cn(
|
|
"min-h-0 flex-1 overflow-y-auto py-1 custom-scrollbar",
|
|
collapsed ? "px-2 space-y-0.5" : "px-3"
|
|
)}
|
|
>
|
|
{visibleSections.map((section, idx) => {
|
|
const sectionId = section.id as SidebarSectionId;
|
|
const isExpanded = expandedSections.has(sectionId);
|
|
const isPinned = pinnedSections.has(sectionId);
|
|
const isFirst = idx === 0;
|
|
const sectionItems = section.children.flatMap((child: any) =>
|
|
child.type === "group" ? child.items : [child]
|
|
);
|
|
|
|
// Collapsed (mini) mode: flat items with dividers between sections
|
|
if (collapsed) {
|
|
return (
|
|
<div key={section.id}>
|
|
{!isFirst && (
|
|
<div className="border-t border-black/5 dark:border-white/5 my-1.5" />
|
|
)}
|
|
{sectionItems.map(renderNavLink)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sections without a visible title (e.g. Home) render items directly
|
|
if (section.showTitle === false) {
|
|
return (
|
|
<div key={section.id} className={cn("space-y-0.5", !isFirst && "mt-1")}>
|
|
{sectionItems.map(renderNavLink)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Expanded mode: collapsible section with pin
|
|
return (
|
|
<div key={section.id} className={isFirst ? "space-y-0.5" : "mt-2"}>
|
|
<div
|
|
className="flex items-center gap-0.5 px-2 py-1 rounded-md hover:bg-surface/30 transition-colors cursor-pointer group/header"
|
|
onClick={() => toggleSection(sectionId)}
|
|
role="button"
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<span className="flex-1 text-[10px] font-semibold text-text-muted/60 uppercase tracking-wider group-hover/header:text-text-muted/90 transition-colors">
|
|
{section.title}
|
|
</span>
|
|
|
|
{/* Pin button — right side near chevron */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
togglePin(sectionId);
|
|
}}
|
|
title={isPinned ? t("unpinSection") : t("pinSectionOpen")}
|
|
className={cn(
|
|
"p-0.5 rounded transition-all shrink-0",
|
|
isPinned
|
|
? "text-primary opacity-100"
|
|
: "text-text-muted/30 opacity-0 group-hover/header:opacity-100 hover:text-text-muted/70"
|
|
)}
|
|
>
|
|
<span
|
|
className="material-symbols-outlined"
|
|
style={{
|
|
fontSize: "10px",
|
|
...(isPinned ? { fontVariationSettings: "'FILL' 1" } : {}),
|
|
}}
|
|
>
|
|
push_pin
|
|
</span>
|
|
</button>
|
|
|
|
<span
|
|
className={cn(
|
|
"material-symbols-outlined text-[14px] text-text-muted/40 transition-all duration-200 group-hover/header:text-text-muted/70 shrink-0",
|
|
isExpanded && "rotate-180"
|
|
)}
|
|
>
|
|
expand_more
|
|
</span>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{section.children.map((child: any) => {
|
|
if (child.type === "group") {
|
|
if (child.items.length === 0) return null;
|
|
return (
|
|
<div key={child.id} className="mt-2">
|
|
{/* Visual sub-group separator */}
|
|
<div className="flex items-center gap-1.5 px-2 py-0.5 mb-0.5">
|
|
<div className="h-px flex-1 bg-black/8 dark:bg-white/8" />
|
|
<span className="text-[8px] font-semibold text-text-muted/40 uppercase tracking-widest">
|
|
{child.title}
|
|
</span>
|
|
</div>
|
|
{child.items.map(renderNavLink)}
|
|
</div>
|
|
);
|
|
}
|
|
return renderNavLink(child);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{!isE2EMode && <CloudSyncStatus collapsed={collapsed} />}
|
|
|
|
<div
|
|
className={cn(
|
|
"shrink-0 border-t border-black/5 dark:border-white/5",
|
|
collapsed ? "p-2 flex flex-col gap-1" : "p-2 flex gap-2"
|
|
)}
|
|
style={{
|
|
paddingBottom: isMacElectron ? "calc(0.5rem + var(--desktop-safe-bottom))" : undefined,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => setShowRestartModal(true)}
|
|
title={t("restart")}
|
|
className={cn(
|
|
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
|
|
"text-amber-500 hover:bg-amber-500/10 border border-amber-500/20 hover:border-amber-500/40",
|
|
collapsed ? "p-2" : "flex-1 min-w-0 px-2 py-1.5 text-xs"
|
|
)}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
|
{!collapsed && <span className="truncate">{t("restart")}</span>}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowShutdownModal(true)}
|
|
title={t("shutdown")}
|
|
className={cn(
|
|
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
|
|
"text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40",
|
|
collapsed ? "p-2" : "flex-1 min-w-0 px-2 py-1.5 text-xs"
|
|
)}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">power_settings_new</span>
|
|
{!collapsed && <span className="truncate">{t("shutdown")}</span>}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Styled tooltip for collapsed (mini) sidebar */}
|
|
{collapsed && hoveredItem && (
|
|
<div
|
|
className="fixed z-[200] pointer-events-none flex items-center"
|
|
style={{ left: hoveredItem.x, top: hoveredItem.y, transform: "translateY(-50%)" }}
|
|
>
|
|
<div className="w-0 h-0 border-t-[5px] border-b-[5px] border-r-[6px] border-t-transparent border-b-transparent border-r-sidebar dark:border-r-sidebar" />
|
|
<div className="px-2.5 py-1.5 bg-sidebar text-text-main text-xs font-medium rounded-md shadow-lg border border-black/10 dark:border-white/10 whitespace-nowrap">
|
|
{hoveredItem.label}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmModal
|
|
isOpen={showShutdownModal}
|
|
onClose={() => setShowShutdownModal(false)}
|
|
onConfirm={handleShutdown}
|
|
title={t("shutdown")}
|
|
message={t("shutdownConfirm")}
|
|
confirmText={t("shutdown")}
|
|
cancelText={tc("cancel")}
|
|
variant="danger"
|
|
loading={isShuttingDown}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
isOpen={showRestartModal}
|
|
onClose={() => setShowRestartModal(false)}
|
|
onConfirm={handleRestart}
|
|
title={t("restart")}
|
|
message={t("restartConfirm")}
|
|
confirmText={t("restart")}
|
|
cancelText={tc("cancel")}
|
|
variant="warning"
|
|
loading={isRestarting}
|
|
/>
|
|
|
|
{isDisconnected && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
|
<div className="text-center p-8">
|
|
<div className="flex items-center justify-center size-16 rounded-full bg-red-500/20 text-red-500 mx-auto mb-4">
|
|
<span className="material-symbols-outlined text-[32px]">power_off</span>
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-white mb-2">{t("serverDisconnected")}</h2>
|
|
<p className="text-text-muted mb-6">{t("serverDisconnectedMsg")}</p>
|
|
<Button variant="secondary" onClick={() => globalThis.location.reload()}>
|
|
{t("reloadPage")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|