Files
ComfyUI-Lora-Manager/static/css/layout.css
T
Will Miao 470d85cca6 feat(scanner): scan a single root and keep unreachable entries (#1108)
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).

Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
  scope reconcile normally, everything outside is neither re-read nor removed.
  The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
  read are kept and reported instead of removed. Sources: a configured root that
  is not reachable (drive switched off while LM runs), a directory os.walk
  failed to enter (permissions / I/O error / Windows junction to an offline
  drive), and a known first-level symlink whose target is gone
  (Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
  not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
  scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
  exposed as ModelScanner.last_reconcile_summary, returned by
  BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
  leftwards with real parent segments until unique, shared by the walk-progress
  line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
  full_rebuild=true); GET /roots gains root_details (label / reachable / cached
  count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
  own directory is unreachable - browsing the grid with a drive off used to
  strip preview references from the persistent cache.

Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
  their cached counts; offline roots stay clickable and explain themselves; rows
  are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
  entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
  an argument-forwarding refreshModels) so a page facade cannot drop them: the
  first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
  to the info style instead of rendering an unstyled box.

Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
2026-10-07 15:01:26 +08:00

748 lines
18 KiB
CSS

.page-content {
height: calc(100vh - 48px); /* Full height minus header */
margin-top: 48px; /* Push down below header */
width: 100%;
position: relative;
overflow-x: hidden; /* Prevent horizontal scrolling */
overflow-y: scroll; /* Enable vertical scrolling */
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--space-2);
position: relative;
z-index: var(--z-base);
}
/* Sticky controls container */
.controls {
position: sticky;
top: -54px;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
padding: var(--space-1) 0;
box-shadow: var(--shadow-xs);
}
/* Wrapper around the controls bar and breadcrumb nav. With the sticky-controls
setting off it is transparent to layout (display: contents), preserving the
original behavior (only the breadcrumb stays visible). When enabled, the whole
wrapper sticks as one unit so the two bars can never drift apart. */
.sticky-topbar {
display: contents;
}
body.sticky-controls .sticky-topbar {
display: block;
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
box-shadow: var(--shadow-xs);
}
/* Responsive container for larger screens */
@media (min-width: 2150px) {
.container {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.container {
max-width: 2400px;
}
}
.controls-right {
display: flex;
align-items: center;
gap: 8px;
/* Push to the right of the row. Because it is also the flex item that is
allowed to drop to a second row, an auto margin keeps it right-aligned on
either row — no width: 100% / viewport breakpoint needed. */
margin-left: auto;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
viewport width. Viewport-based wrapping wasted space on high-DPI displays
(e.g. a 2560px monitor at 200% scaling reports a ~1280px CSS viewport even
when the window is maximized). */
flex-wrap: wrap;
width: 100%;
}
.action-buttons {
display: flex;
align-items: center;
gap: var(--space-2);
/* Let the group shrink rather than overflow so .controls-right only wraps
when it really has to. */
flex-wrap: wrap;
flex-shrink: 1;
min-width: 0;
}
/* Action button styling */
.control-group {
position: relative;
}
.control-group button {
/* Keeps the toolbar visually even without forcing the row to overflow (the
old 100px floor pushed the total past the container on wide screens). */
min-width: 90px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: var(--border-radius-xs);
padding: 4px 10px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
transition: var(--transition-base);
cursor: pointer;
box-shadow: var(--shadow-xs);
}
.control-group button:hover,
.control-group button:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.control-group button:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.control-group button i {
opacity: 0.8;
transition: opacity 0.2s ease;
}
.control-group button:hover i,
.control-group button:focus-visible i {
opacity: 1;
}
.control-group button:disabled {
cursor: not-allowed;
opacity: 0.6;
pointer-events: none;
}
.control-group button.loading,
.dropdown-toggle.loading {
cursor: wait;
opacity: 0.7;
pointer-events: none;
}
/* Controls */
.control-group button.favorite-filter,
.control-group button.update-filter {
position: relative;
overflow: hidden;
}
.control-group button.favorite-filter.active {
background: var(--lora-accent);
color: white;
}
.control-group button.favorite-filter i {
margin-right: 4px;
color: var(--favorite-color);
}
.control-group button.update-filter i {
margin-right: 4px;
color: var(--lora-accent);
}
.control-group button.update-filter.active {
background: var(--lora-accent);
color: white;
}
.control-group button.update-filter.active i {
color: white;
}
.update-filter-group .dropdown-main.update-filter.active + .dropdown-toggle {
background: var(--lora-accent);
border-color: var(--lora-accent);
color: white;
}
.update-filter-group .dropdown-main.update-filter.active + .dropdown-toggle i {
color: inherit;
}
/* Active state for buttons that can be toggled */
.control-group button.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */
.layout-toggle-group {
display: flex;
gap: 0;
}
.layout-toggle-group .layout-toggle-btn {
min-width: 36px;
width: 36px;
padding: 4px 0;
border-radius: 0;
}
.layout-toggle-group .layout-toggle-btn:first-child {
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
border-right: none;
}
.layout-toggle-group .layout-toggle-btn:last-child {
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
}
.layout-toggle-group .layout-toggle-btn:hover,
.layout-toggle-group .layout-toggle-btn:focus-visible {
transform: none;
box-shadow: var(--shadow-xs);
}
/* Keyboard shortcut indicator styling */
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 500;
line-height: 1;
text-transform: uppercase;
border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
color: var(--shortcut-text);
opacity: 0.8;
transition: var(--transition-base);
}
.control-group button:hover .shortcut-key {
opacity: 1;
background-color: var(--shortcut-bg-hover);
border-color: var(--shortcut-border-hover);
}
/* Invert the keycap on active (accent-filled) buttons for contrast.
Must come after the hover rule above so it wins on active+hover. */
.control-group button.active .shortcut-key,
.control-group button.active:hover .shortcut-key {
color: var(--lora-accent);
background: rgba(255, 255, 255, 0.92);
border-color: transparent;
opacity: 1;
}
/* Ensure correct vertical alignment for text+shortcut */
.control-group button span {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Select dropdown styling */
.control-group select {
min-width: 100px;
padding: 4px 26px 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 14px;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
/* Style for optgroups */
.control-group select optgroup {
font-weight: 600;
font-style: normal;
color: var(--text-color);
background-color: var(--card-bg);
}
.control-group select option {
padding: 4px 8px;
background-color: var(--card-bg);
color: var(--text-color);
}
/* Dark theme optgroup styling */
[data-theme="dark"] .control-group select optgroup {
background-color: var(--card-bg);
color: var(--text-color);
}
[data-theme="dark"] .control-group select option {
background-color: var(--card-bg);
color: var(--text-color);
}
.control-group select:hover {
border-color: var(--lora-accent);
background-color: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.control-group select:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
}
/* Disabled sort dropdown — used when VLM custom filter is active */
.control-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.control-group select:disabled:hover {
border-color: var(--border-color);
background-color: var(--bg-color);
transform: none;
box-shadow: none;
}
/* === Sort dropdown — decoupled trigger width ===========================
The native <select> sizes its trigger to the widest <option>, wasting
horizontal space when a short option is selected. This custom trigger
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. */
.sort-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
max-width: 240px;
padding: 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
.sort-trigger:hover,
.sort-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.sort-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
.sort-dropdown-group.is-disabled .sort-trigger {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
background: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
/* Dropdown menu — sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
}
/* Optgroup label rendered as a section header */
.sort-dropdown-group .sort-optgroup-label {
padding: 8px 12px 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
cursor: default;
user-select: none;
}
.sort-dropdown-group .sort-optgroup-label:first-child {
padding-top: 4px;
}
/* Option items */
.sort-dropdown-group .sort-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before {
content: '';
width: 14px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
/* Visually hidden native <select> — kept in the DOM for programmatic access.
High-specificity selector overrides .control-group select { min-width: 100px }. */
.control-group .sort-select-native {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
/* Ensure hidden class works properly */
.hidden {
display: none !important;
}
/* Icon-only button style */
.icon-only {
min-width: unset !important;
width: 32px !important;
padding: 0 !important;
height: 32px !important;
}
/* Back to Top Button */
.back-to-top {
position: fixed;
bottom: 85px;
right: 30px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: var(--transition-slow);
z-index: var(--z-overlay);
box-shadow: var(--shadow-sm);
}
.back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
/* Prevent text selection in control and header areas */
.control-group button,
.control-group select,
.bulk-operations-panel,
.app-header,
.header-branding,
.app-title,
.main-nav,
.nav-item,
.header-actions button,
.header-controls {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Dropdown Button Styling */
.dropdown-group {
position: relative;
display: flex;
}
.dropdown-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid var(--border-color);
}
.dropdown-toggle {
width: 24px !important;
min-width: unset !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
min-width: max(100%, max-content);
width: max-content;
padding: 5px 0;
margin: 2px 0 0;
font-size: 0.85em;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-xl);
}
.dropdown-group.active .dropdown-menu {
display: block;
}
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
the sticky breadcrumb. */
.controls:has(.dropdown-group.active) {
z-index: var(--z-header);
}
.dropdown-item {
display: block;
padding: 6px 15px;
clear: both;
font-weight: 400;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-item.disabled {
cursor: default;
opacity: 0.6;
pointer-events: none;
}
.dropdown-item:hover {
background-color: oklch(var(--lora-accent) / 0.1);
}
.dropdown-item i {
margin-right: 8px;
width: 16px;
text-align: center;
}
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
one row at common laptop/high-DPI widths. The buttons are allowed to shrink to
their content (min-width: 0) here, which is what reclaims the space the old
100px floor plus a forced wrap used to waste. .controls-right is deliberately
NOT forced onto its own row: it stays inline while it fits and only drops to a
second row (staying right-aligned through its auto margin) when it does not. */
@media (max-width: 1500px) {
.action-buttons {
gap: var(--space-1);
}
.control-group button {
min-width: 0;
padding: 4px 8px;
}
.control-group select {
min-width: 0;
}
}
@media (max-width: 768px) {
.actions {
gap: var(--space-2);
}
.action-buttons {
width: 100%;
}
/* Narrow screens: let the right-hand group wrap below the buttons, still
right-aligned. */
.controls-right {
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-1);
}
.control-group button:hover {
transform: none; /* Disable hover effects on mobile */
}
.control-group select:hover {
transform: none; /* Disable hover effects on mobile */
}
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */
}
.dropdown-menu {
left: auto;
right: 0; /* Align to right on mobile */
}
/* Adjust controls padding on mobile */
.controls {
padding: 10px 0;
}
}
/* Refresh dropdown: per-root scan scope (issue #1108). The list can hold one
entry per configured model root, so it scrolls instead of growing past the
viewport. Offline roots stay clickable (clicking explains the state) and are
only visually de-emphasised — unlike .dropdown-item.disabled, which swallows
the click via pointer-events: none. */
.dropdown-separator {
height: 1px;
margin: 5px 0;
background-color: var(--border-color);
}
.dropdown-section-title {
padding: 4px 15px 2px;
font-size: 0.85em;
font-weight: 600;
color: var(--text-color);
opacity: 0.6;
}
.dropdown-scope-list {
max-height: 45vh;
overflow-y: auto;
}
.scan-root-item.is-offline {
cursor: default;
opacity: 0.6;
}
.scan-root-label {
margin-right: 12px;
}
.scan-root-count,
.scan-root-offline {
float: right;
margin-left: 16px;
font-size: 0.9em;
opacity: 0.7;
}
.scan-root-item.is-offline .scan-root-offline {
opacity: 0.9;
}