Refactor AuthFilesPage styles and layout; improve filter controls and display settings

- Updated SCSS styles for filter sections, controls, and buttons for better consistency and responsiveness.
- Replaced filter controls with a more flexible toolbar layout.
- Introduced display settings button with a popover for page size and compact mode options.
- Enhanced accessibility with appropriate ARIA attributes and keyboard navigation support.
- Cleaned up unused styles and improved overall code readability.
This commit is contained in:
Supra4E8C
2026-07-25 02:54:34 +08:00
parent 05631cfb43
commit 7793321b18
5 changed files with 351 additions and 1439 deletions
@@ -1,296 +0,0 @@
.squircleClip {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.cardShadow {
display: block;
width: 100%;
max-width: 100%;
filter: drop-shadow(0 8px 18px rgb(0 0 0 / 0.12));
transition: filter 0.2s ease;
}
.card {
width: 100%;
min-width: 0;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: 10px;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--bg-primary) 88%, transparent),
color-mix(in srgb, var(--bg-secondary) 78%, transparent)
);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--floating-surface) 44%, transparent);
padding: 12px 14px;
user-select: none;
font-family: inherit;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 9px;
}
.headerLeft {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
font-weight: 500;
perspective: 280px;
perspective-origin: center 120%;
}
.labelText {
color: var(--text-primary);
font-weight: 700;
line-height: 1.3;
}
.statusText {
display: inline-block;
color: var(--text-secondary);
transition:
color 0.3s,
text-shadow 0.3s;
will-change: transform, opacity, filter;
vertical-align: middle;
transform-origin: center bottom;
transform: rotateX(0deg) translateY(0);
}
.statusTextGlowing {
color: color-mix(in srgb, var(--warning-color) 82%, var(--text-primary));
text-shadow: 0 0 12px color-mix(in srgb, var(--warning-color) 42%, transparent);
font-weight: 700;
}
@keyframes flipUpFromBottom {
0% {
opacity: 0;
transform: translateY(18px) rotateX(-80deg);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0) rotateX(0deg);
filter: blur(0);
}
}
.statusTextAnimateUp {
animation: flipUpFromBottom 0.42s cubic-bezier(0.33, 1, 0.68, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.statusTextAnimateUp {
animation: none;
}
}
.scaleLabels {
display: flex;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 5px;
letter-spacing: 0;
}
/* ── track ─────────────────────────────────── */
.trackWrapper {
--thumb-size: 22px;
--thumb-half: calc(var(--thumb-size) / 2);
--track-inset: var(--thumb-half);
position: relative;
height: 26px;
border-radius: 9px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 88%, var(--bg-primary));
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.12);
isolation: isolate;
}
.trackWrapper:focus-within {
border-color: color-mix(in srgb, var(--primary-color) 72%, var(--border-color));
box-shadow:
inset 0 1px 2px rgb(0 0 0 / 0.12),
0 0 0 2px color-mix(in srgb, var(--primary-color) 14%, transparent);
}
.trackWrapperActive {
border-color: color-mix(in srgb, var(--warning-color) 38%, var(--border-color));
}
.trackWrapperActive:focus-within {
border-color: color-mix(in srgb, var(--warning-color) 46%, var(--border-color));
box-shadow:
inset 0 1px 2px rgb(0 0 0 / 0.12),
0 0 0 2px color-mix(in srgb, var(--warning-color) 14%, transparent);
}
.trackBg {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, color-mix(in srgb, var(--primary-color) 9%, transparent), transparent 62%),
color-mix(in srgb, var(--bg-secondary) 86%, var(--bg-primary));
z-index: 0;
}
.fireCanvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0;
mix-blend-mode: screen;
z-index: 2;
transition: opacity 0.3s;
}
.trackWrapperActive .fireCanvas {
opacity: 1;
z-index: 4;
}
.dotsLayer {
position: absolute;
inset: 0 var(--track-inset);
pointer-events: none;
z-index: 3;
}
.dot {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-quaternary) 72%, transparent);
top: 50%;
transform: translate(-50%, -50%);
transition:
opacity 0.6s,
background-color 0.2s ease,
transform 0.2s ease;
z-index: 6;
}
.trackWrapperActive .dot {
background: color-mix(in srgb, var(--warning-color) 52%, var(--text-quaternary));
opacity: 0.35;
}
.trackWrapperFull .dot {
opacity: 0;
}
/* ── range input ───────────────────────────── */
.slider {
position: absolute;
inset: 0 var(--track-inset);
width: auto;
height: 100%;
box-sizing: border-box;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
z-index: 5;
outline: none;
margin: 0;
padding: 0;
}
.slider:disabled {
cursor: not-allowed;
}
.slider::-webkit-slider-runnable-track {
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: var(--thumb-size);
height: var(--thumb-size);
border: 0;
background: transparent;
opacity: 0;
cursor: grab;
}
.slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.slider::-moz-range-thumb {
width: var(--thumb-size);
height: var(--thumb-size);
border: 0;
background: transparent;
opacity: 0;
cursor: grab;
}
.slider::-moz-range-thumb:active {
cursor: grabbing;
}
.slider::-moz-range-track {
background: transparent;
border: none;
height: 100%;
}
.thumbLayer {
position: absolute;
inset: 0 var(--track-inset);
pointer-events: none;
z-index: 7;
}
.thumb {
position: absolute;
top: 50%;
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: 9px;
background: linear-gradient(
170deg,
var(--primary-contrast, #fff) 0%,
color-mix(in srgb, var(--primary-contrast, #fff) 82%, var(--bg-tertiary)) 100%
);
border: 1px solid color-mix(in srgb, var(--border-color) 48%, transparent);
box-shadow:
0 1px 2px rgb(0 0 0 / 0.18),
0 4px 10px rgb(0 0 0 / 0.14),
inset 0 0.5px 0 rgb(255 255 255 / 0.82);
transform: translate(-50%, -50%);
transition:
left 0.22s cubic-bezier(0.33, 1, 0.68, 1),
box-shadow 0.4s ease,
transform 0.15s ease;
}
.trackWrapperDragging .thumb {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.22),
0 2px 6px rgb(0 0 0 / 0.18),
0 0 0 3px color-mix(in srgb, var(--primary-color) 14%, transparent),
inset 0 0.5px 0 rgb(255 255 255 / 0.72);
transform: translate(-50%, -50%) scale(0.95);
}
@@ -1,245 +0,0 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useStatusFilterWebglFire } from '@/features/authFiles/hooks/useStatusFilterWebglFire';
import styles from './AuthFilesStatusFilterCard.module.scss';
export type AuthFilesStatusFilterOption = {
value: string;
label: string;
};
export type AuthFilesStatusFilterCardProps = {
label: string;
minLabel?: string;
maxLabel?: string;
value: string;
options: AuthFilesStatusFilterOption[];
onChange: (value: string) => void;
};
export function AuthFilesStatusFilterCard({
label,
minLabel,
maxLabel,
value,
options,
onChange,
}: AuthFilesStatusFilterCardProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const draggingRef = useRef(false);
const [isAnimating, setIsAnimating] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevActiveRef = useRef(false);
const activeIndex = Math.max(
0,
options.findIndex((option) => option.value === value)
);
const sliderValue = (activeIndex / Math.max(1, options.length - 1)) * 100;
const isActive = activeIndex === options.length - 1;
const isFull = sliderValue === 100;
const currentOption = options[activeIndex] ?? options[0];
useEffect(() => {
if (prevActiveRef.current !== isActive) {
// Trigger a one-off flip animation when the active state crosses the
// threshold, mirroring the original EffortCard behavior.
setIsAnimating(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsAnimating(false);
timerRef.current = null;
}, 460);
prevActiveRef.current = isActive;
}
}, [isActive]);
useEffect(
() => () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
},
[]
);
useStatusFilterWebglFire(canvasRef, sliderValue / 100, isActive);
const uid = useId().replace(/:/g, '');
const clipId = `status-filter-card-${uid}`;
const clipTrackId = `status-filter-track-${uid}`;
const cardClip = useMemo(
() => ({
clipPath: `url(#${clipId})`,
}),
[clipId]
);
const trackClip = useMemo(
() => ({
clipPath: `url(#${clipTrackId})`,
}),
[clipTrackId]
);
const canvasMask = useMemo(() => {
const p = Math.min(sliderValue + 2, 100);
const gradient = `linear-gradient(to right, black 0%, black ${p}%, transparent ${p}%)`;
return {
maskImage: gradient,
WebkitMaskImage: gradient,
};
}, [sliderValue]);
const thumbStyle = useMemo(() => ({ left: `${sliderValue}%` }), [sliderValue]);
const selectIndex = useCallback(
(nextIndex: number) => {
const clamped = Math.max(0, Math.min(options.length - 1, nextIndex));
const nextValue = options[clamped]?.value;
if (nextValue && nextValue !== value) {
onChange(nextValue);
}
},
[onChange, options, value]
);
const selectFromPercent = useCallback(
(raw: number) => {
const step = 100 / Math.max(1, options.length - 1);
const nextIndex = Math.max(
0,
Math.min(options.length - 1, Math.round(raw / step + 1e-9))
);
selectIndex(nextIndex);
},
[options.length, selectIndex]
);
const selectFromClientX = useCallback(
(clientX: number) => {
const rect = inputRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const raw = ((clientX - rect.left) / rect.width) * 100;
selectFromPercent(Math.max(0, Math.min(100, raw)));
},
[selectFromPercent]
);
const handleTrackPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (options.length <= 1) return;
event.preventDefault();
draggingRef.current = true;
setIsDragging(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
inputRef.current?.focus({ preventScroll: true });
selectFromClientX(event.clientX);
};
const handleTrackPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
selectFromClientX(event.clientX);
};
const handleTrackPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = false;
setIsDragging(false);
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
};
const handleInput = (event: React.ChangeEvent<HTMLInputElement>) => {
selectFromPercent(Number(event.currentTarget.value));
};
const trackWrapperClass = [
styles.trackWrapper,
isActive ? styles.trackWrapperActive : '',
isFull ? styles.trackWrapperFull : '',
isDragging ? styles.trackWrapperDragging : '',
].join(' ');
const statusClass = [
styles.statusText,
isActive ? styles.statusTextGlowing : '',
isAnimating ? styles.statusTextAnimateUp : '',
].join(' ');
return (
<div className={styles.cardShadow}>
<svg className={styles.squircleClip} xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d="M 0.053,0 C 0.029,0 0.012,0.008 0.005,0.02 C 0.002,0.028 0,0.038 0,0.053 L 0,0.947 C 0,0.962 0.002,0.972 0.005,0.98 C 0.012,0.992 0.029,1 0.053,1 L 0.947,1 C 0.971,1 0.988,0.992 0.995,0.98 C 0.998,0.972 1,0.962 1,0.947 L 1,0.053 C 1,0.038 0.998,0.028 0.995,0.02 C 0.988,0.008 0.971,0 0.947,0 Z" />
</clipPath>
<clipPath id={clipTrackId} clipPathUnits="objectBoundingBox">
<path d="M 0.033,0 C 0.018,0 0.007,0.012 0.003,0.035 C 0.001,0.055 0,0.1 0,0.15 L 0,0.85 C 0,0.9 0.001,0.945 0.003,0.965 C 0.007,0.988 0.018,1 0.033,1 L 0.967,1 C 0.982,1 0.993,0.988 0.997,0.965 C 0.999,0.945 1,0.9 1,0.85 L 1,0.15 C 1,0.1 0.999,0.055 0.997,0.035 C 0.993,0.012 0.982,0 0.967,0 Z" />
</clipPath>
</defs>
</svg>
<div className={styles.card} style={cardClip}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.labelText}>{label}</span>
<span className={statusClass}>{currentOption.label}</span>
</div>
</div>
<div className={styles.scaleLabels}>
<span>{minLabel ?? options[0]?.label}</span>
<span>{maxLabel ?? options[options.length - 1]?.label}</span>
</div>
<div
className={trackWrapperClass}
style={trackClip}
onPointerDown={handleTrackPointerDown}
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerEnd}
onPointerCancel={handleTrackPointerEnd}
>
<div className={styles.trackBg} />
<div className={styles.dotsLayer}>
{options.map((option, index) => {
const stepCount = Math.max(1, options.length - 1);
const left = `${(index / stepCount) * 100}%`;
return (
<span
key={option.value}
className={styles.dot}
style={{ left }}
aria-hidden="true"
/>
);
})}
</div>
<canvas
ref={canvasRef}
className={styles.fireCanvas}
style={canvasMask}
aria-hidden="true"
/>
<input
ref={inputRef}
type="range"
min={0}
max={100}
step="any"
value={sliderValue}
className={styles.slider}
onChange={handleInput}
aria-label={label}
disabled={options.length <= 1}
/>
<div className={styles.thumbLayer} aria-hidden="true">
<span className={styles.thumb} style={thumbStyle} />
</div>
</div>
</div>
</div>
);
}
@@ -1,648 +0,0 @@
import { useEffect, useRef } from 'react';
const VERT = `#version 300 es
layout(location=0) in vec2 a_pos;
out vec2 v_uv;
void main(){ v_uv=a_pos*0.5+0.5; gl_Position=vec4(a_pos,0.0,1.0); }
`;
const FRAG_SIM = `#version 300 es
precision highp float;
in vec2 v_uv; out vec4 fc;
uniform float u_time, u_slider, u_elapsed, u_grid_x;
uniform vec3 u_ember, u_glow, u_core;
uniform sampler2D u_back;
float hash(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
void main(){
vec2 uv=v_uv;
vec2 g=uv*vec2(u_grid_x,6.0);
vec2 id=floor(g);
vec2 cf=fract(g);
float h=hash(id);
vec2 ap=abs(cf-0.5);
float cell=smoothstep(0.34,0.22,max(ap.x*0.9,ap.y));
vec3 prev=texture(u_back,uv).rgb;
float fade_mask = smoothstep(0.0, 0.45, uv.x);
vec3 decay = prev * 0.90 * fade_mask;
float act=smoothstep(0.95,1.0,u_slider);
if(act<0.01||u_elapsed<0.0){ fc=vec4(decay,1.0); return; }
float t=u_time;
float cellDelay = h * 1.2;
float cellAge = max(u_elapsed - cellDelay, 0.0);
float ignited = step(0.001, cellAge);
float cellSpd = 0.85 + h * 0.30;
float eased = 1.0 - pow(1.0 - clamp(cellAge / 2.5, 0.0, 1.0), 3.0);
float dist = eased * u_slider * cellSpd * ignited;
float cellOff = (h - 0.5) * 0.05;
float front = max(u_slider - dist - cellOff, 0.02);
float tail = max(u_slider - front, 0.001);
float inZ = step(front - 0.003, uv.x) * step(uv.x, u_slider + 0.003);
float dn = clamp(max(u_slider - uv.x, 0.0) / tail, 0.0, 1.0);
float bright = pow(1.0 - dn, 0.65);
bright = max(bright, 0.04 * ignited) * inZ;
bright *= 1.0 - smoothstep(0.94, 1.05, dn);
float es = mix(0.15, 0.5, min(u_elapsed / 1.0, 1.0));
float vy = abs(uv.y - 0.5) * 2.0;
float vf = pow(max(1.0 - vy * vy * 0.45, 0.0), 0.75);
float ts = mix(0.85, 1.0, min(u_elapsed / 1.5, 1.0));
float f1 = sin(uv.x * 30.0 + t * 15.0 * ts + h * 6.28);
float f2 = sin(uv.x * 17.0 + t * 8.0 * ts + h * 3.14);
float f3 = sin(uv.x * 52.0 + t * 25.0 * ts + h * 10.0);
float flame = smoothstep(0.08, 0.92, (f1 + f2 * 0.5 + f3 * 0.25) * 0.35 + 0.5);
float r1 = sin(dn * 16.0 - t * 5.0 * ts + h * 3.0);
float r2 = sin(dn * 8.0 - t * 2.5 * ts + h * 5.0);
float rhythm = smoothstep(-0.15, 0.55, r1) * (r2 * 0.5 + 0.5);
rhythm = pow(max(rhythm, 0.0), 1.2);
float avgSpd = dist / max(cellAge, 0.001);
float age = max(cellAge - max(u_slider - uv.x, 0.0) / max(avgSpd, 0.001), 0.0);
float flash = step(0.0, age) * exp(-age * 3.2);
float sp = fract(t * (0.38 + h * 0.15) + h * 7.0);
float sX = u_slider - sp * tail;
float sY = 0.5 + sin(sp * 11.0 + h * 6.28) * 0.28;
float spark = smoothstep(0.014, 0.0, abs(uv.x - sX))
* smoothstep(0.18, 0.0, abs(uv.y - sY))
* (1.0 - sp) * (1.0 - sp) * es;
float energy = bright * vf * (flame * 0.42 + rhythm * 0.38)
+ flash * bright * vf * 0.55
+ spark * 0.7 * inZ;
energy *= es;
float edgeBase = exp(-pow((uv.x - front) * 18.0, 2.0));
float ef1 = sin(uv.x * 45.0 + t * 20.0 * ts + h * 6.28) * 0.5 + 0.5;
float ef2 = sin(uv.x * 28.0 + t * 11.0 * ts + h * 3.14) * 0.5 + 0.5;
float edge = edgeBase * (0.25 + ef1 * ef2 * 1.5) * 1.6 * act * es;
float leadD = front - uv.x;
float leadZone = smoothstep(0.07, 0.0, leadD) * step(0.0, leadD) * vf;
float h2 = hash(id + vec2(99.0, 33.0));
float leadF = sin(leadD * 100.0 + t * 20.0 * ts + h2 * 6.28) * 0.5 + 0.5;
float leadSpark = leadZone * step(0.6, h2) * leadF * act * es * 0.5;
float total = energy + edge + leadSpark;
vec3 ember = u_ember;
vec3 wpur = u_glow;
vec3 wht = u_core;
float temp = 1.0 - dn;
vec3 col = mix(ember, wpur, temp);
col = mix(col, wht, pow(temp, 4.5));
col *= total;
float pulse = sin(t * 2.8) * 0.15 + 1.0;
float core = exp(-pow((uv.x - u_slider) * 16.0, 2.0));
col += wht * core * 2.2 * pulse * act * es;
col += wpur * exp(-pow((uv.x - u_slider) * 3.5, 2.0)) * 0.12 * act * es;
col *= cell;
col *= fade_mask;
fc = vec4(min(decay + col, vec3(1.5)), 1.0);
}
`;
const FRAG_BLUR = `#version 300 es
precision highp float;
in vec2 v_uv; out vec4 fc;
uniform sampler2D u_tex;
uniform vec2 u_dir, u_res;
uniform float u_ext;
vec3 s(vec2 uv){
vec3 c=texture(u_tex,uv).rgb;
return u_ext>0.5 && dot(c,vec3(0.2126,0.7152,0.0722))<0.3 ? vec3(0.0) : c;
}
void main(){
vec2 o=u_dir*1.8/u_res;
vec3 r=s(v_uv)*0.227027;
r+=s(v_uv+o)*0.194595; r+=s(v_uv-o)*0.194595;
r+=s(v_uv+o*2.0)*0.121622;r+=s(v_uv-o*2.0)*0.121622;
r+=s(v_uv+o*3.0)*0.054054;r+=s(v_uv-o*3.0)*0.054054;
fc=vec4(r,1.0);
}
`;
const FRAG_COMP = `#version 300 es
precision highp float;
in vec2 v_uv; out vec4 fc;
uniform sampler2D u_scene, u_glow;
void main(){
vec3 s=texture(u_scene,v_uv).rgb;
vec3 g=texture(u_glow,v_uv).rgb;
fc=vec4(1.0-exp(-(s+g*1.2+s*g*0.35)*1.15),1.0);
}
`;
type FBO = {
fbo: WebGLFramebuffer;
tex: WebGLTexture;
};
type Rgb = readonly [number, number, number];
type ThemeFireColors = {
ember: Rgb;
glow: Rgb;
core: Rgb;
};
const BASE_GRID_WIDTH_PX = 256;
const BASE_GRID_COLUMNS = 58;
const FALLBACK_WARNING_COLOR: Rgb = [198 / 255, 87 / 255, 70 / 255];
const FALLBACK_PRIMARY_COLOR: Rgb = [139 / 255, 134 / 255, 128 / 255];
const FALLBACK_CONTRAST_COLOR: Rgb = [1, 1, 1];
const FALLBACK_FIRE_COLORS: ThemeFireColors = {
ember: [0.38, 0.14, 0.1],
glow: [0.78, 0.34, 0.27],
core: [1, 0.9, 0.86],
};
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function parseCssColor(value: string): Rgb | null {
const trimmed = value.trim();
if (!trimmed) return null;
const hex = trimmed.match(/^#([\da-f]{3}|[\da-f]{6})$/i);
if (hex) {
const raw = hex[1];
const expanded =
raw.length === 3
? raw
.split('')
.map((part) => `${part}${part}`)
.join('')
: raw;
const parsed = Number.parseInt(expanded, 16);
return [
((parsed >> 16) & 255) / 255,
((parsed >> 8) & 255) / 255,
(parsed & 255) / 255,
];
}
const rgb = trimmed.match(/^rgba?\((.+)\)$/i);
if (!rgb) return null;
const channels = rgb[1]
.replace(/\s*\/.*$/, '')
.split(/[,\s]+/)
.filter(Boolean)
.slice(0, 3);
if (channels.length !== 3) return null;
const normalized = channels.map((channel) => {
if (channel.endsWith('%')) {
return clamp01(Number.parseFloat(channel) / 100);
}
return clamp01(Number.parseFloat(channel) / 255);
});
if (normalized.some((channel) => Number.isNaN(channel))) return null;
return [normalized[0], normalized[1], normalized[2]];
}
function mixRgb(from: Rgb, to: Rgb, toRatio: number): Rgb {
const ratio = clamp01(toRatio);
return [
from[0] * (1 - ratio) + to[0] * ratio,
from[1] * (1 - ratio) + to[1] * ratio,
from[2] * (1 - ratio) + to[2] * ratio,
];
}
function resolveThemeFireColors(canvasEl: HTMLCanvasElement): ThemeFireColors {
const elementStyle = window.getComputedStyle(canvasEl);
const rootStyle = window.getComputedStyle(document.documentElement);
const readCssVar = (name: string) =>
parseCssColor(elementStyle.getPropertyValue(name)) ??
parseCssColor(rootStyle.getPropertyValue(name));
const warning = readCssVar('--warning-color') ?? FALLBACK_WARNING_COLOR;
const primary = readCssVar('--primary-color') ?? FALLBACK_PRIMARY_COLOR;
const contrast = readCssVar('--primary-contrast') ?? FALLBACK_CONTRAST_COLOR;
return {
ember: mixRgb(warning, [0, 0, 0], 0.46),
glow: mixRgb(warning, primary, 0.18),
core: mixRgb(warning, contrast, 0.72),
};
}
export function useStatusFilterWebglFire(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
sliderValue: number,
isActive: boolean
) {
const sliderValueRef = useRef(sliderValue);
const isActiveRef = useRef(isActive);
const ultraStartRef = useRef<number | null>(null);
const apiRef = useRef({ ensureLoop: () => {} });
useEffect(() => {
sliderValueRef.current = sliderValue;
}, [sliderValue]);
useEffect(() => {
isActiveRef.current = isActive;
if (isActive) {
ultraStartRef.current = performance.now();
apiRef.current.ensureLoop();
} else {
ultraStartRef.current = null;
}
}, [isActive]);
useEffect(() => {
let gl: WebGL2RenderingContext | null = null;
let canvasEl: HTMLCanvasElement | null = null;
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeDebounce: ReturnType<typeof setTimeout> | null = null;
let loopRunning = false;
let idleFrames = 0;
let wasActive = false;
let simProg: WebGLProgram | null = null;
let blurProg: WebGLProgram | null = null;
let compProg: WebGLProgram | null = null;
let vao: WebGLVertexArrayObject | null = null;
let vbo: WebGLBuffer | null = null;
let programsReady = false;
let simA: FBO | null = null;
let simB: FBO | null = null;
let blurH: FBO | null = null;
let blurV: FBO | null = null;
const U: Record<string, WebGLUniformLocation | null> = {
simTime: null,
simSlider: null,
simElapsed: null,
simGridX: null,
simEmber: null,
simGlow: null,
simCore: null,
simBack: null,
blurDir: null,
blurExt: null,
blurTex: null,
blurRes: null,
compScene: null,
compGlow: null,
};
const MAX_IDLE = 180;
let gridColumns = BASE_GRID_COLUMNS;
let themeFireColors = FALLBACK_FIRE_COLORS;
let themeObserver: MutationObserver | null = null;
function onContextLost(e: Event) {
e.preventDefault();
}
function onContextRestored() {
compilePrograms();
if (programsReady) {
resize();
if (isActiveRef.current) ensureLoop();
}
}
function compileShader(type: number, src: string) {
if (!gl) return null;
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
function linkProgram(vsSrc: string, fsSrc: string) {
if (!gl) return null;
const v = compileShader(gl.VERTEX_SHADER, vsSrc);
const f = compileShader(gl.FRAGMENT_SHADER, fsSrc);
if (!v || !f) return null;
const p = gl.createProgram();
if (!p) return null;
gl.attachShader(p, v);
gl.attachShader(p, f);
gl.bindAttribLocation(p, 0, 'a_pos');
gl.linkProgram(p);
gl.deleteShader(v);
gl.deleteShader(f);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(p));
return null;
}
return p;
}
function compilePrograms() {
if (!gl) return;
simProg = linkProgram(VERT, FRAG_SIM);
blurProg = linkProgram(VERT, FRAG_BLUR);
compProg = linkProgram(VERT, FRAG_COMP);
if (!simProg || !blurProg || !compProg) return;
vao = gl.createVertexArray();
gl.bindVertexArray(vao);
vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
U.simTime = gl.getUniformLocation(simProg, 'u_time');
U.simSlider = gl.getUniformLocation(simProg, 'u_slider');
U.simElapsed = gl.getUniformLocation(simProg, 'u_elapsed');
U.simGridX = gl.getUniformLocation(simProg, 'u_grid_x');
U.simEmber = gl.getUniformLocation(simProg, 'u_ember');
U.simGlow = gl.getUniformLocation(simProg, 'u_glow');
U.simCore = gl.getUniformLocation(simProg, 'u_core');
U.simBack = gl.getUniformLocation(simProg, 'u_back');
U.blurDir = gl.getUniformLocation(blurProg, 'u_dir');
U.blurExt = gl.getUniformLocation(blurProg, 'u_ext');
U.blurTex = gl.getUniformLocation(blurProg, 'u_tex');
U.blurRes = gl.getUniformLocation(blurProg, 'u_res');
U.compScene = gl.getUniformLocation(compProg, 'u_scene');
U.compGlow = gl.getUniformLocation(compProg, 'u_glow');
programsReady = true;
}
function makeFBO(): FBO | null {
if (!gl || !canvasEl) return null;
const fbo = gl.createFramebuffer();
const tex = gl.createTexture();
if (!fbo || !tex) return null;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
canvasEl.width,
canvasEl.height,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
return { fbo, tex };
}
function createFBOs() {
if (!gl || !canvasEl) return;
simA = makeFBO();
simB = makeFBO();
blurH = makeFBO();
blurV = makeFBO();
}
function destroyFBO(entry: FBO | null) {
if (!gl || !entry) return;
gl.deleteFramebuffer(entry.fbo);
gl.deleteTexture(entry.tex);
}
function destroyFBOs() {
destroyFBO(simA);
simA = null;
destroyFBO(simB);
simB = null;
destroyFBO(blurH);
blurH = null;
destroyFBO(blurV);
blurV = null;
}
function refreshThemeFireColors() {
if (!canvasEl) return;
themeFireColors = resolveThemeFireColors(canvasEl);
}
function destroyPrograms() {
if (!gl) return;
if (simProg) {
gl.deleteProgram(simProg);
simProg = null;
}
if (blurProg) {
gl.deleteProgram(blurProg);
blurProg = null;
}
if (compProg) {
gl.deleteProgram(compProg);
compProg = null;
}
if (vao) {
gl.deleteVertexArray(vao);
vao = null;
}
if (vbo) {
gl.deleteBuffer(vbo);
vbo = null;
}
programsReady = false;
}
function resize() {
if (!gl || !canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const dpr = window.devicePixelRatio || 1;
gridColumns = Math.max(
BASE_GRID_COLUMNS,
Math.round((rect.width / BASE_GRID_WIDTH_PX) * BASE_GRID_COLUMNS)
);
canvasEl.width = Math.round(rect.width * dpr);
canvasEl.height = Math.round(rect.height * dpr);
destroyFBOs();
createFBOs();
}
function ensureLoop() {
if (!simA || !simB) {
resize();
if (!simA || !simB) return;
}
if (loopRunning) {
idleFrames = 0;
return;
}
loopRunning = true;
idleFrames = 0;
wasActive = false;
gl?.bindFramebuffer(gl.FRAMEBUFFER, simA.fbo);
gl?.clear(gl.COLOR_BUFFER_BIT);
gl?.bindFramebuffer(gl.FRAMEBUFFER, simB.fbo);
gl?.clear(gl.COLOR_BUFFER_BIT);
rafId = requestAnimationFrame(render);
}
function render(t: number) {
const active = isActiveRef.current;
if (!active && !wasActive) {
if (++idleFrames > MAX_IDLE) {
loopRunning = false;
rafId = null;
return;
}
rafId = requestAnimationFrame(render);
return;
}
idleFrames = 0;
if (active && !wasActive) {
gl?.bindFramebuffer(gl.FRAMEBUFFER, simA?.fbo ?? null);
gl?.clear(gl.COLOR_BUFFER_BIT);
gl?.bindFramebuffer(gl.FRAMEBUFFER, simB?.fbo ?? null);
gl?.clear(gl.COLOR_BUFFER_BIT);
}
wasActive = active;
if (!gl || !simA || !simB || !blurH || !blurV) return;
const elapsed = active ? (performance.now() - (ultraStartRef.current || 0)) / 1000 : -1.0;
const sv = sliderValueRef.current;
gl.viewport(0, 0, canvasEl?.width ?? 0, canvasEl?.height ?? 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, simB.fbo);
gl.useProgram(simProg);
gl.uniform1f(U.simTime, t * 0.001);
gl.uniform1f(U.simSlider, sv);
gl.uniform1f(U.simElapsed, elapsed);
gl.uniform1f(U.simGridX, gridColumns);
gl.uniform3f(U.simEmber, ...themeFireColors.ember);
gl.uniform3f(U.simGlow, ...themeFireColors.glow);
gl.uniform3f(U.simCore, ...themeFireColors.core);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, simA.tex);
gl.uniform1i(U.simBack, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.useProgram(blurProg);
gl.uniform2f(U.blurRes, canvasEl?.width ?? 0, canvasEl?.height ?? 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, blurH.fbo);
gl.uniform2f(U.blurDir, 1.0, 0.0);
gl.uniform1f(U.blurExt, 1.0);
gl.bindTexture(gl.TEXTURE_2D, simB.tex);
gl.uniform1i(U.blurTex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindFramebuffer(gl.FRAMEBUFFER, blurV.fbo);
gl.uniform2f(U.blurDir, 0.0, 1.0);
gl.uniform1f(U.blurExt, 0.0);
gl.bindTexture(gl.TEXTURE_2D, blurH.tex);
gl.uniform1i(U.blurTex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(compProg);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, simB.tex);
gl.uniform1i(U.compScene, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, blurV.tex);
gl.uniform1i(U.compGlow, 1);
gl.drawArrays(gl.TRIANGLES, 0, 6);
const tmp = simA;
simA = simB;
simB = tmp;
rafId = requestAnimationFrame(render);
}
function init() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('webgl2', {
preserveDrawingBuffer: false,
antialias: false,
});
if (!ctx) {
console.warn('WebGL2 not supported');
return;
}
gl = ctx;
canvasEl = canvas;
refreshThemeFireColors();
canvas.addEventListener('webglcontextlost', onContextLost);
canvas.addEventListener('webglcontextrestored', onContextRestored);
themeObserver = new MutationObserver(refreshThemeFireColors);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class', 'style'],
});
compilePrograms();
if (!programsReady) return;
resizeObserver = new ResizeObserver(() => {
if (resizeDebounce) clearTimeout(resizeDebounce);
resizeDebounce = setTimeout(resize, 80);
});
resizeObserver.observe(canvas);
resize();
}
init();
apiRef.current.ensureLoop = ensureLoop;
// If the component already starts in the active state, kick off the
// render loop now that WebGL is ready.
if (isActiveRef.current) {
ensureLoop();
}
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (resizeDebounce) {
clearTimeout(resizeDebounce);
resizeDebounce = null;
}
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
loopRunning = false;
destroyFBOs();
destroyPrograms();
if (canvasEl) {
canvasEl.removeEventListener('webglcontextlost', onContextLost);
canvasEl.removeEventListener('webglcontextrestored', onContextRestored);
}
gl = null;
canvasEl = null;
};
}, [canvasRef]);
}
+195 -136
View File
@@ -82,7 +82,7 @@
.filterSection {
display: flex;
flex-direction: column;
gap: $spacing-md;
gap: 12px;
margin-bottom: $spacing-lg;
}
@@ -90,14 +90,6 @@
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
@include mobile {
padding: 10px;
}
}
.filterAllIconWrap {
@@ -118,13 +110,6 @@
color: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary));
}
.filterContent {
display: flex;
flex-direction: column;
gap: $spacing-md;
min-width: 0;
}
.filterTags {
display: flex;
flex-direction: row;
@@ -229,138 +214,212 @@
color: var(--filter-color);
}
.filterControlsPanel {
position: relative;
overflow: hidden;
padding: 16px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 56%, transparent);
.filterToolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
@include mobile {
padding: 12px;
}
}
.filterControls {
display: grid;
grid-template-columns: minmax(340px, 1fr) minmax(340px, 420px);
grid-template-areas:
'search options'
'status options';
gap: 12px;
align-items: stretch;
// 修复 Input 组件在 filterItem 中的嵌套样式
:global(.form-group) {
margin: 0;
}
:global(.input) {
height: 42px;
height: 40px;
box-sizing: border-box;
}
@media (max-width: 1100px) {
grid-template-columns: 1fr;
grid-template-areas:
'search'
'status'
'options';
}
@include mobile {
grid-template-columns: 1fr;
grid-template-areas:
'search'
'status'
'options';
}
}
.filterSearchItem {
grid-area: search;
min-width: 0;
width: 100%;
.toolbarSearch {
flex: 1 1 280px;
min-width: 220px;
}
.filterItem {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
label {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
}
}
.filterToggleItem {
grid-area: status;
min-width: 0;
width: 100%;
}
.filterOptionsCard {
grid-area: options;
display: grid;
grid-template-columns: minmax(96px, 0.58fr) minmax(156px, 1fr);
gap: 12px;
align-content: center;
min-width: 0;
padding: 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
background: color-mix(in srgb, var(--bg-primary) 56%, transparent);
@include mobile {
grid-template-columns: 1fr;
}
}
.filterOptionsControl {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
label {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
}
}
.filterOptionsToggle {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 42px;
padding-top: 2px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 46%, transparent);
> label {
width: 100%;
min-width: 0;
justify-content: center;
}
}
.filterToggleLabel {
.statusFilterGroup {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
min-height: 40px;
padding: 3px;
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
box-sizing: border-box;
}
.statusFilterButton {
min-width: 52px;
height: 32px;
padding: 0 11px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
color $transition-fast,
background-color $transition-fast,
box-shadow $transition-fast;
&:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary-color) 48%, transparent);
outline-offset: -2px;
}
}
.statusFilterButtonActive {
color: var(--primary-active);
background: var(--bg-primary);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
&:hover {
color: var(--primary-active);
background: var(--bg-primary);
}
}
.statusFilterButtonProblem.statusFilterButtonActive {
color: var(--danger-color);
background: color-mix(in srgb, var(--danger-color) 10%, var(--bg-primary));
}
.sortControl {
display: flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
min-width: 0;
}
.toolbarControlLabel {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.displaySettings {
position: relative;
flex: 0 0 auto;
}
.displaySettingsButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
font: inherit;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
color $transition-fast,
border-color $transition-fast,
background-color $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--border-hover);
background: var(--bg-secondary);
}
&:focus-visible {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
}
}
.displaySettingsButtonActive {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary-color) 52%, var(--border-color));
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
}
.displaySettingsPopover {
position: absolute;
z-index: 40;
top: calc(100% + 8px);
right: 0;
display: flex;
flex-direction: column;
width: 250px;
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-primary);
box-shadow: var(--shadow-lg);
}
.displaySettingsRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 52px;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
line-height: 1.25;
+ .displaySettingsRow {
border-top: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent);
}
label {
cursor: pointer;
}
}
@media (max-width: 900px) {
.toolbarSearch {
flex-basis: 100%;
}
}
@include mobile {
.toolbarSearch,
.statusFilterGroup {
flex: 1 1 100%;
width: 100%;
}
.statusFilterGroup {
overflow-x: auto;
}
.statusFilterButton {
flex: 1 0 auto;
}
.sortControl {
flex: 1 1 180px;
}
.sortControl .sortSelect {
flex: 1;
width: auto;
}
.displaySettingsPopover {
width: min(250px, calc(100vw - 48px));
}
}
.searchInput {
@@ -374,15 +433,15 @@
}
.pageSizeSelect {
width: 100%;
padding: 8px 12px;
width: 84px;
height: 34px;
padding: 6px 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: 8px;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
font-size: 13px;
cursor: text;
height: 42px;
box-sizing: border-box;
&:focus {
@@ -1380,7 +1439,7 @@
}
.sortSelect {
width: 100%;
width: 132px;
min-width: 0;
}
+156 -114
View File
@@ -21,10 +21,9 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { IconFilterAll, IconSearch } from '@/components/ui/icons';
import { IconFilterAll, IconSearch, IconSlidersHorizontal } from '@/components/ui/icons';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { AuthFilesStatusFilterCard } from '@/features/authFiles/components/AuthFilesStatusFilterCard';
import { copyToClipboard } from '@/utils/clipboard';
import {
MAX_CARD_PAGE_SIZE,
@@ -116,12 +115,14 @@ export function AuthFilesPage() {
const [pageSizeInput, setPageSizeInput] = useState('9');
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
const [displaySettingsOpen, setDisplaySettingsOpen] = useState(false);
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [uiStateHydrated, setUiStateHydrated] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
const displaySettingsRef = useRef<HTMLDivElement>(null);
const {
files,
@@ -206,6 +207,28 @@ export function AuthFilesPage() {
const disabledOnly = statusFilterMode === 'disabled';
const enabledOnly = statusFilterMode === 'enabled';
useEffect(() => {
if (!displaySettingsOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!displaySettingsRef.current?.contains(event.target as Node)) {
setDisplaySettingsOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setDisplaySettingsOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [displaySettingsOpen]);
useEffect(() => {
const persistedCompactMode = readPersistedAuthFilesCompactMode();
if (typeof persistedCompactMode === 'boolean') {
@@ -749,26 +772,73 @@ export function AuthFilesPage() {
<div className={styles.filterSection}>
{renderFilterTags()}
<div className={styles.filterContent}>
<div className={styles.filterControlsPanel}>
<div className={styles.filterControls}>
<div className={`${styles.filterItem} ${styles.filterSearchItem}`}>
<label>{t('auth_files.search_label')}</label>
<Input
className={styles.searchInput}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
rightElement={<IconSearch className={styles.searchIcon} size={18} />}
/>
</div>
<div className={styles.filterOptionsCard}>
<div className={styles.filterOptionsControl}>
<label>{t('auth_files.page_size_label')}</label>
<div className={styles.filterToolbar}>
<div className={styles.toolbarSearch}>
<Input
className={styles.searchInput}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
aria-label={t('auth_files.search_label')}
rightElement={<IconSearch className={styles.searchIcon} size={18} />}
/>
</div>
<div
className={styles.statusFilterGroup}
role="group"
aria-label={t('auth_files.problem_filter_label')}
>
{statusFilterOptions.map((option) => {
const isActive = statusFilterMode === option.value;
const isProblem = option.value === 'problem';
return (
<button
key={option.value}
type="button"
className={`${styles.statusFilterButton} ${isActive ? styles.statusFilterButtonActive : ''} ${isProblem ? styles.statusFilterButtonProblem : ''}`}
aria-pressed={isActive}
onClick={() => handleStatusFilterModeChange(option.value)}
>
{option.label}
</button>
);
})}
</div>
<div className={styles.sortControl}>
<span className={styles.toolbarControlLabel}>{t('auth_files.sort_label')}</span>
<Select
className={styles.sortSelect}
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('auth_files.sort_label')}
fullWidth
/>
</div>
<div className={styles.displaySettings} ref={displaySettingsRef}>
<button
type="button"
className={`${styles.displaySettingsButton} ${displaySettingsOpen ? styles.displaySettingsButtonActive : ''}`}
aria-expanded={displaySettingsOpen}
aria-controls="auth-files-display-settings"
onClick={() => setDisplaySettingsOpen((open) => !open)}
>
<IconSlidersHorizontal size={17} />
<span>{t('auth_files.display_options_label')}</span>
</button>
{displaySettingsOpen && (
<div id="auth-files-display-settings" className={styles.displaySettingsPopover}>
<div className={styles.displaySettingsRow}>
<label htmlFor="auth-files-page-size">{t('auth_files.page_size_label')}</label>
<input
id="auth-files-page-size"
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
@@ -784,108 +854,80 @@ export function AuthFilesPage() {
}}
/>
</div>
<div className={styles.filterOptionsControl}>
<label>{t('auth_files.sort_label')}</label>
<Select
className={styles.sortSelect}
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('auth_files.sort_label')}
fullWidth
/>
</div>
<div className={styles.filterOptionsToggle}>
<div className={styles.displaySettingsRow}>
<span>{t('auth_files.compact_mode_label')}</span>
<ToggleSwitch
checked={compactMode}
onChange={(value) => setCompactMode(value)}
ariaLabel={t('auth_files.compact_mode_label')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.compact_mode_label')}
</span>
}
/>
</div>
</div>
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
<label>{t('auth_files.display_options_label')}</label>
<AuthFilesStatusFilterCard
label={t('auth_files.problem_filter_label')}
minLabel={statusFilterOptions[0]?.label}
maxLabel={statusFilterOptions[statusFilterOptions.length - 1]?.label}
value={statusFilterMode}
options={statusFilterOptions}
onChange={(next) =>
handleStatusFilterModeChange(next as AuthFilesStatusFilterMode)
}
/>
</div>
</div>
)}
</div>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''} ${compactMode ? styles.fileGridCompact : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
file={file}
compact={compactMode}
selected={selectedFiles.has(file.name)}
resolvedTheme={resolvedTheme}
disableControls={disableControls}
deleting={deleting}
statusUpdating={statusUpdating}
quotaFilterType={quotaFilterType}
statusBarCache={statusBarCache}
onShowModels={showModels}
onDownload={handleDownload}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
onToggleSelect={toggleSelect}
/>
))}
</div>
)}
{!loading && sorted.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: sorted.length,
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</div>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''} ${compactMode ? styles.fileGridCompact : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
file={file}
compact={compactMode}
selected={selectedFiles.has(file.name)}
resolvedTheme={resolvedTheme}
disableControls={disableControls}
deleting={deleting}
statusUpdating={statusUpdating}
quotaFilterType={quotaFilterType}
statusBarCache={statusBarCache}
onShowModels={showModels}
onDownload={handleDownload}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
onToggleSelect={toggleSelect}
/>
))}
</div>
)}
{!loading && sorted.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: sorted.length,
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</div>
</Card>