mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-01 15:34:05 +08:00
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:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user