Add local routing rules to Harbor
This commit is contained in:
@@ -88,10 +88,14 @@ function App() {
|
||||
: new HarborApiError({ code: err?.code }, err?.status);
|
||||
setError({
|
||||
context,
|
||||
message: safeError.message,
|
||||
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
||||
? 'Правила изменились. Закройте панель и откройте её снова.'
|
||||
: safeError.message,
|
||||
code: safeError.code,
|
||||
correlationId: safeError.correlationId,
|
||||
retry: safeError.retryable ? () => run(key, action, context) : null,
|
||||
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
|
||||
? () => run(key, action, context)
|
||||
: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -156,6 +160,11 @@ function App() {
|
||||
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
||||
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
||||
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
||||
onSaveRouteRules={(rules, expectedRevision) => run(
|
||||
'routeRules',
|
||||
() => api.routeRules.update(rules, expectedRevision),
|
||||
'routing',
|
||||
)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,12 @@ export const api = {
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
},
|
||||
routeRules: {
|
||||
update: (rules, expectedRevision) => request('/api/route-rules', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rules, expectedRevision }),
|
||||
}),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
|
||||
@@ -133,6 +133,7 @@ const operationProgress = {
|
||||
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||||
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
|
||||
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||||
routeRules: ['routing', 'Применяем локальные правила…'],
|
||||
};
|
||||
|
||||
function InlineProgress({ operations, context }) {
|
||||
@@ -195,6 +196,111 @@ function InstructionBlock({ block, open, onToggle }) {
|
||||
);
|
||||
}
|
||||
|
||||
const ROUTE_RULE_OPTIONS = [
|
||||
['domain', 'Точный домен'],
|
||||
['domain_suffix', 'Суффикс'],
|
||||
['domain_keyword', 'Содержит'],
|
||||
];
|
||||
|
||||
const ROUTE_RULE_PLACEHOLDERS = {
|
||||
domain: 'example.com или полный URL',
|
||||
domain_suffix: 'example.org',
|
||||
domain_keyword: 'cdn',
|
||||
};
|
||||
|
||||
function LocalRulesPanel({
|
||||
open,
|
||||
rules,
|
||||
blocked,
|
||||
error,
|
||||
operations,
|
||||
panelRef,
|
||||
onAdd,
|
||||
onChange,
|
||||
onRemove,
|
||||
onClose,
|
||||
onSave,
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
id="client-local-rules"
|
||||
className={`client-local-rules${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="local-rules-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
>
|
||||
<div className="client-local-rules-sheet">
|
||||
<header className="client-local-rules-header">
|
||||
<span>Маршрутизация</span>
|
||||
<h2 id="local-rules-title">Локальные правила</h2>
|
||||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||
</header>
|
||||
|
||||
<form className="client-local-rules-form" onSubmit={onSave}>
|
||||
<section className="client-local-rules-group" aria-labelledby="built-in-rules-title">
|
||||
<span id="built-in-rules-title">Базовое правило</span>
|
||||
<div className="client-local-rule is-built-in">
|
||||
<strong>Суффикс</strong>
|
||||
<code>*.ru</code>
|
||||
<small>Всегда напрямую</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="client-local-rules-group" aria-labelledby="custom-rules-title">
|
||||
<span id="custom-rules-title">Ваши правила</span>
|
||||
<div className="client-local-rules-list">
|
||||
{rules.map((rule, index) => (
|
||||
<div className="client-local-rule" key={index}>
|
||||
<select
|
||||
aria-label={`Тип правила ${index + 1}`}
|
||||
value={rule.type}
|
||||
onChange={(event) => onChange(index, 'type', event.target.value)}
|
||||
>
|
||||
{ROUTE_RULE_OPTIONS.map(([value, label]) => (
|
||||
<option value={value} key={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
aria-label={`Значение правила ${index + 1}`}
|
||||
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
|
||||
value={rule.value}
|
||||
onChange={(event) => onChange(index, 'value', event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!rules.length && <p className="client-local-rules-empty">Дополнительных исключений пока нет.</p>}
|
||||
</div>
|
||||
<button className="client-local-rule-add" type="button" onClick={onAdd}>+ Добавить правило</button>
|
||||
</section>
|
||||
|
||||
<p className="client-local-rules-note">
|
||||
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||||
</p>
|
||||
<InlineError error={error} context="routing" />
|
||||
<InlineProgress operations={operations} context="routing" />
|
||||
<div className="client-local-rules-actions">
|
||||
<button type="button" onClick={onClose}>Отмена</button>
|
||||
<button className="is-primary" type="submit" disabled={blocked}>Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DurationPart({ name, children }) {
|
||||
return (
|
||||
<span
|
||||
@@ -305,6 +411,7 @@ export function ClientOverviewPage({
|
||||
onRestart,
|
||||
onStop,
|
||||
onSetGatewayAuto,
|
||||
onSaveRouteRules,
|
||||
}) {
|
||||
const isGateway = state?.mode === 'gateway';
|
||||
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
||||
@@ -335,6 +442,9 @@ export function ClientOverviewPage({
|
||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||
const [serversLeaving, setServersLeaving] = useState(false);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||||
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||
const subscriptionInputRef = useRef(null);
|
||||
@@ -342,6 +452,8 @@ export function ClientOverviewPage({
|
||||
const copyTimerRef = useRef(null);
|
||||
const instructionsPanelRef = useRef(null);
|
||||
const instructionsToggleRef = useRef(null);
|
||||
const localRulesPanelRef = useRef(null);
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
const validationRequests = useRef(null);
|
||||
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
||||
@@ -381,6 +493,7 @@ export function ClientOverviewPage({
|
||||
const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
|
||||
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
@@ -498,6 +611,7 @@ export function ClientOverviewPage({
|
||||
if (!hasSubscription) {
|
||||
setEditingSubscription(true);
|
||||
setInstructionsOpen(false);
|
||||
setLocalRulesOpen(false);
|
||||
}
|
||||
}, [hasSubscription]);
|
||||
|
||||
@@ -551,6 +665,22 @@ export function ClientOverviewPage({
|
||||
return () => document.removeEventListener('keydown', closeOnEscape);
|
||||
}, [instructionsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localRulesOpen) return undefined;
|
||||
const closeLocalRules = (event) => {
|
||||
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
||||
if (localRulesPanelRef.current?.contains(event.target)) return;
|
||||
if (localRulesToggleRef.current?.contains(event.target)) return;
|
||||
setLocalRulesOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeLocalRules);
|
||||
document.addEventListener('keydown', closeLocalRules);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeLocalRules);
|
||||
document.removeEventListener('keydown', closeLocalRules);
|
||||
};
|
||||
}, [localRulesOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instructionsOpen) return undefined;
|
||||
const closeInstructionsOutside = (event) => {
|
||||
@@ -644,6 +774,25 @@ export function ClientOverviewPage({
|
||||
});
|
||||
}
|
||||
|
||||
function openLocalRules() {
|
||||
setInstructionsOpen(false);
|
||||
setLocalRulesDraft((state?.route?.localRules?.custom || []).map((rule) => ({ ...rule })));
|
||||
setLocalRulesRevision(state?.revision || 0);
|
||||
setLocalRulesOpen(true);
|
||||
}
|
||||
|
||||
function changeLocalRule(index, field, value) {
|
||||
setLocalRulesDraft((rules) => rules.map((rule, ruleIndex) => (
|
||||
ruleIndex === index ? { ...rule, [field]: value } : rule
|
||||
)));
|
||||
}
|
||||
|
||||
async function saveLocalRules(event) {
|
||||
event.preventDefault();
|
||||
if (!await onSaveRouteRules(localRulesDraft, localRulesRevision)) return;
|
||||
setLocalRulesOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
@@ -661,7 +810,10 @@ export function ClientOverviewPage({
|
||||
aria-expanded={instructionsOpen}
|
||||
aria-controls="client-instructions"
|
||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
onClick={() => setInstructionsOpen((open) => !open)}
|
||||
onClick={() => {
|
||||
setLocalRulesOpen(false);
|
||||
setInstructionsOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m3 5 7 7-7 7" />
|
||||
@@ -670,6 +822,22 @@ export function ClientOverviewPage({
|
||||
</svg>
|
||||
<span>Как использовать</span>
|
||||
</button>}
|
||||
{hasSubscription && subscriptionContentReady && <button
|
||||
ref={localRulesToggleRef}
|
||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={localRulesOpen}
|
||||
aria-controls="client-local-rules"
|
||||
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
onClick={() => localRulesOpen ? setLocalRulesOpen(false) : openLocalRules()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||||
<circle cx="15" cy="7" r="2" />
|
||||
<circle cx="9" cy="17" r="2" />
|
||||
</svg>
|
||||
<span>Локальные правила</span>
|
||||
</button>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||
{showPower && (
|
||||
<section className="client-power-section" aria-labelledby="connection-title">
|
||||
@@ -1040,6 +1208,20 @@ export function ClientOverviewPage({
|
||||
</div>
|
||||
</aside>}
|
||||
|
||||
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
|
||||
open={localRulesOpen}
|
||||
rules={localRulesDraft}
|
||||
blocked={routeRulesBlocked}
|
||||
error={error}
|
||||
operations={operations}
|
||||
panelRef={localRulesPanelRef}
|
||||
onAdd={() => setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])}
|
||||
onChange={changeLocalRule}
|
||||
onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))}
|
||||
onClose={() => setLocalRulesOpen(false)}
|
||||
onSave={saveLocalRules}
|
||||
/>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export const OPERATION_CONFLICTS = Object.freeze({
|
||||
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
||||
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
||||
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
||||
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto'],
|
||||
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto'],
|
||||
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete'],
|
||||
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'],
|
||||
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'],
|
||||
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
||||
});
|
||||
|
||||
export function operationBlocked(operations, key) {
|
||||
|
||||
@@ -717,6 +717,317 @@ p {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.client-local-rules-toggle {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
z-index: 30;
|
||||
width: 42px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transition: color 240ms ease, transform 440ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle svg {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: color 240ms ease, filter 360ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle span {
|
||||
position: absolute;
|
||||
right: 48px;
|
||||
width: max-content;
|
||||
color: var(--client-text);
|
||||
font: 700 10px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
pointer-events: none;
|
||||
transform: translateX(8px);
|
||||
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle:hover,
|
||||
.client-local-rules-toggle:focus-visible {
|
||||
outline: none;
|
||||
color: var(--client-accent);
|
||||
transform: translateY(-50%) translateX(-3px);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle:hover svg,
|
||||
.client-local-rules-toggle:focus-visible svg {
|
||||
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 52%, transparent));
|
||||
}
|
||||
|
||||
.client-local-rules-toggle:hover span,
|
||||
.client-local-rules-toggle:focus-visible span {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle.is-open {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle.is-open svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.client-local-rules-toggle.is-open span {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.client-local-rules {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 20;
|
||||
width: min(480px, 100vw);
|
||||
overflow-y: auto;
|
||||
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||
color: var(--client-text);
|
||||
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(104%);
|
||||
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
||||
}
|
||||
|
||||
.client-local-rules.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.client-local-rules-sheet {
|
||||
min-height: 100%;
|
||||
padding: 54px 72px 72px 34px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.client-local-rules-header {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0 8px 34px;
|
||||
}
|
||||
|
||||
.client-local-rules-header > span,
|
||||
.client-local-rules-group > span {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-local-rules-header h2 {
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.client-local-rules-header p {
|
||||
max-width: 46ch;
|
||||
color: var(--client-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.client-local-rules-form,
|
||||
.client-local-rules-group,
|
||||
.client-local-rules-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.client-local-rules-form {
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.client-local-rules-group {
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.client-local-rules-list {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-local-rule {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr) 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 13px;
|
||||
background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
|
||||
box-shadow: 0 10px 28px oklch(0.1 0.015 145 / 0.055);
|
||||
transition: background 240ms ease, box-shadow 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-local-rule:focus-within {
|
||||
background: color-mix(in oklch, var(--client-panel) 74%, var(--client-bg));
|
||||
box-shadow: 0 14px 34px oklch(0.1 0.015 145 / 0.09);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.client-local-rule.is-built-in {
|
||||
grid-template-columns: 92px minmax(0, 1fr) auto;
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-local-rule.is-built-in strong {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-local-rule.is-built-in code {
|
||||
color: var(--client-accent);
|
||||
font: 700 13px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
text-shadow: 0 0 12px color-mix(in oklch, var(--client-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.client-local-rule.is-built-in small {
|
||||
font-size: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-local-rule select,
|
||||
.client-local-rule input {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
outline: 0;
|
||||
background: color-mix(in oklch, var(--client-control) 72%, transparent);
|
||||
color: var(--client-text);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.client-local-rule select {
|
||||
padding: 0 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-local-rule input {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.client-local-rule input::placeholder {
|
||||
color: color-mix(in oklch, var(--client-muted) 72%, transparent);
|
||||
}
|
||||
|
||||
.client-local-rule select:focus-visible,
|
||||
.client-local-rule input:focus-visible,
|
||||
.client-local-rule button:focus-visible,
|
||||
.client-local-rule-add:focus-visible,
|
||||
.client-local-rules-actions button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-local-rule > button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font-size: 17px;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease, background 180ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.client-local-rule > button:hover {
|
||||
background: color-mix(in oklch, oklch(0.68 0.15 28) 10%, transparent);
|
||||
color: oklch(0.68 0.15 28);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.client-local-rules-empty {
|
||||
padding: 14px 4px 4px;
|
||||
color: var(--client-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.client-local-rule-add {
|
||||
width: fit-content;
|
||||
padding: 7px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-accent);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-local-rule-add:hover {
|
||||
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.client-local-rules-note {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklch, var(--client-control) 44%, transparent);
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.client-inline-error.is-routing {
|
||||
position: static;
|
||||
width: 100%;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.client-local-rules-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.client-local-rules-actions button {
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: color-mix(in oklch, var(--client-control) 72%, transparent);
|
||||
color: var(--client-text);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease, color 200ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.client-local-rules-actions button:hover:not(:disabled) {
|
||||
background: var(--client-control);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.client-local-rules-actions .is-primary {
|
||||
background: color-mix(in oklch, var(--client-accent) 18%, var(--client-control));
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-local-rules-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.client-instructions {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
@@ -2197,10 +2508,43 @@ p {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.client-local-rules-toggle {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.client-instructions-sheet {
|
||||
padding: 40px 18px 60px 58px;
|
||||
}
|
||||
|
||||
.client-local-rules-sheet {
|
||||
padding: 40px 58px 60px 18px;
|
||||
}
|
||||
|
||||
.client-local-rules-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.client-local-rule,
|
||||
.client-local-rule.is-built-in {
|
||||
grid-template-columns: 1fr 32px;
|
||||
}
|
||||
|
||||
.client-local-rule select,
|
||||
.client-local-rule.is-built-in strong {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.client-local-rule input,
|
||||
.client-local-rule.is-built-in code {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.client-local-rule.is-built-in small {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.client-instructions-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -2260,6 +2604,17 @@ p {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.client-local-rules,
|
||||
.client-local-rules-toggle,
|
||||
.client-local-rules-toggle svg,
|
||||
.client-local-rules-toggle span,
|
||||
.client-local-rule,
|
||||
.client-local-rule > button,
|
||||
.client-local-rules-actions button {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.harbor-brand {
|
||||
transition: none;
|
||||
animation: none;
|
||||
|
||||
Reference in New Issue
Block a user