Polish client duration and tooltip interactions
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/harbor-mark.svg" type="image/svg+xml" />
|
||||
<link id="harbor-favicon" rel="icon" href="/harbor-connect.svg" type="image/svg+xml" />
|
||||
<title>Harbor</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
8
public/harbor-connect.svg
Normal file
8
public/harbor-connect.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="9" fill="#101812"/>
|
||||
<circle cx="16" cy="16" r="11" fill="#56c9bd" opacity=".09"/>
|
||||
<g fill="none" stroke="#62d6c9" stroke-width="2.6" stroke-linecap="round">
|
||||
<path d="M16 6.5v9"/>
|
||||
<path d="M10.1 10.3a8 8 0 1 0 11.8 0"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
8
public/harbor-gateway.svg
Normal file
8
public/harbor-gateway.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="9" fill="#18150f"/>
|
||||
<circle cx="16" cy="7" r="2.5" fill="none" stroke="#efad58" stroke-width="2.3"/>
|
||||
<g fill="none" stroke="#efad58" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 9.5V25M10.5 14h11"/>
|
||||
<path d="M16 27c-5 0-8-2.8-9.5-6.5M16 27c5 0 8-2.8 9.5-6.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
@@ -31,7 +31,12 @@ function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.mode) document.title = state.mode === 'gateway' ? 'Harbor Gateway' : 'Harbor Connect';
|
||||
if (!state?.mode) return;
|
||||
const isGateway = state.mode === 'gateway';
|
||||
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
|
||||
document.getElementById('harbor-favicon').href = isGateway
|
||||
? '/harbor-gateway.svg'
|
||||
: '/harbor-connect.svg';
|
||||
}, [state?.mode]);
|
||||
|
||||
async function run(action) {
|
||||
|
||||
@@ -14,6 +14,11 @@ import { formatBytes } from '../utils/format.js';
|
||||
import { instructionBlocks } from '../instructions.js';
|
||||
|
||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||
|
||||
function CloudTooltip({ children }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
}
|
||||
|
||||
function InstructionStep({ step }) {
|
||||
if (typeof step === 'string') return step;
|
||||
@@ -66,7 +71,7 @@ function InstructionBlock({ block, open, onToggle }) {
|
||||
function DurationPart({ name, children }) {
|
||||
return (
|
||||
<span
|
||||
className={`client-duration-part${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
|
||||
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@@ -176,7 +181,13 @@ export function ClientOverviewPage({
|
||||
const showPower = hasSubscription && Boolean(selectedTag);
|
||||
const canStart = Boolean(selectedTag || state?.configExists);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [durationMode, setDurationMode] = useState('digital');
|
||||
const [durationMode, setDurationMode] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
|
||||
} catch {
|
||||
return 'digital';
|
||||
}
|
||||
});
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
||||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||
@@ -449,7 +460,15 @@ export function ClientOverviewPage({
|
||||
}
|
||||
|
||||
function toggleDurationMode() {
|
||||
setDurationMode((mode) => mode === 'digital' ? 'words' : 'digital');
|
||||
setDurationMode((mode) => {
|
||||
const nextMode = mode === 'digital' ? 'words' : 'digital';
|
||||
try {
|
||||
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
|
||||
} catch {
|
||||
// The visual preference still works for this session.
|
||||
}
|
||||
return nextMode;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -502,11 +521,10 @@ export function ClientOverviewPage({
|
||||
<div className="client-state-detail">
|
||||
{connected ? (
|
||||
<button
|
||||
className={`client-duration-toggle${durationMode === 'words' ? ' is-words' : ''}`}
|
||||
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||||
type="button"
|
||||
key="duration"
|
||||
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
title={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
onClick={toggleDurationMode}
|
||||
>
|
||||
<span className="client-duration-stack">
|
||||
@@ -516,7 +534,7 @@ export function ClientOverviewPage({
|
||||
>
|
||||
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="seconds-value">{String(duration.seconds.value).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="seconds-value" key={`digital-${duration.seconds.value}`}>{String(duration.seconds.value).padStart(2, '0')}</DurationPart>
|
||||
</time>
|
||||
<time
|
||||
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
||||
@@ -531,12 +549,13 @@ export function ClientOverviewPage({
|
||||
.filter(([name, part]) => part.value || name === 'seconds')
|
||||
.map(([name, part]) => (
|
||||
<span className="client-duration-unit" data-unit={name} key={name}>
|
||||
<DurationPart name={`${name}-value`}>{part.value}</DurationPart>{' '}
|
||||
<DurationPart name={`${name}-value`} key={`${name}-${name === 'seconds' ? part.value : 'value'}`}>{part.value}</DurationPart>{' '}
|
||||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||
</span>
|
||||
))}
|
||||
</time>
|
||||
</span>
|
||||
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
|
||||
</button>
|
||||
) : (
|
||||
<p key="hint">
|
||||
@@ -651,22 +670,21 @@ export function ClientOverviewPage({
|
||||
<div className="client-subscription-heading">
|
||||
<span>Ваша подписка</span>
|
||||
<button
|
||||
className="client-subscription-refresh"
|
||||
className="client-subscription-refresh client-tooltip-anchor"
|
||||
type="button"
|
||||
aria-label="Обновить подписку"
|
||||
title="Обновить подписку"
|
||||
disabled={refreshingInfo}
|
||||
onClick={refreshSubscription}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||
</svg>
|
||||
<CloudTooltip>Обновить подписку</CloudTooltip>
|
||||
</button>
|
||||
<button
|
||||
className="client-subscription-delete"
|
||||
className="client-subscription-delete client-tooltip-anchor"
|
||||
type="button"
|
||||
aria-label="Удалить подписку"
|
||||
title="Удалить подписку"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
@@ -674,6 +692,7 @@ export function ClientOverviewPage({
|
||||
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
|
||||
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
|
||||
</svg>
|
||||
<CloudTooltip>Удалить подписку</CloudTooltip>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -346,7 +346,7 @@ p {
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in oklch, var(--client-panel) 64%, transparent);
|
||||
background: color-mix(in oklch, var(--client-panel) 68%, transparent);
|
||||
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
|
||||
backdrop-filter: blur(8px) saturate(0.9);
|
||||
color: var(--client-muted);
|
||||
@@ -359,7 +359,7 @@ p {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
transform: translateY(3px);
|
||||
transition: opacity 240ms ease, filter 320ms ease, transform 400ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 400ms;
|
||||
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
|
||||
}
|
||||
|
||||
.harbor-mode-tooltip strong {
|
||||
@@ -373,7 +373,7 @@ p {
|
||||
visibility: visible;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
transition-delay: 120ms, 120ms, 120ms, 0s;
|
||||
transition-delay: 20ms, 20ms, 20ms, 0s;
|
||||
}
|
||||
|
||||
.harbor-brand-control:hover .harbor-mode-swap,
|
||||
@@ -968,6 +968,16 @@ p {
|
||||
content: ' ';
|
||||
}
|
||||
|
||||
.client-duration-seconds-value {
|
||||
display: inline-block;
|
||||
animation: client-second-tick 380ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes client-second-tick {
|
||||
from { opacity: 0.28; filter: blur(3px); transform: scale(0.9); }
|
||||
to { opacity: 1; filter: blur(0); transform: scale(1); }
|
||||
}
|
||||
|
||||
.client-duration-toggle {
|
||||
width: 290px;
|
||||
min-height: 24px;
|
||||
@@ -990,6 +1000,44 @@ p {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.client-tooltip-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.client-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
z-index: 12;
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklch, var(--client-panel) 68%, transparent);
|
||||
box-shadow: 0 7px 22px oklch(0.08 0.015 145 / 0.1);
|
||||
backdrop-filter: blur(10px) saturate(0.9);
|
||||
color: var(--client-text);
|
||||
font: 600 10px/1.35 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
letter-spacing: 0;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -2px);
|
||||
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
|
||||
}
|
||||
|
||||
.client-tooltip-anchor:hover > .client-tooltip,
|
||||
.client-tooltip-anchor:focus-visible > .client-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
filter: blur(0);
|
||||
transform: translate(-50%, 0);
|
||||
transition-delay: 20ms, 20ms, 20ms, 0s;
|
||||
}
|
||||
|
||||
.client-power-section p {
|
||||
min-height: 0;
|
||||
line-height: 20px;
|
||||
@@ -1855,6 +1903,7 @@ p {
|
||||
.client-access-point,
|
||||
.client-copy-feedback,
|
||||
.client-duration,
|
||||
.client-duration-seconds-value,
|
||||
.client-instructions,
|
||||
.client-instructions-toggle,
|
||||
.client-instructions-toggle svg,
|
||||
@@ -1899,6 +1948,10 @@ p {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.client-tooltip {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.client-state-copy h2,
|
||||
.client-state-detail > * {
|
||||
animation: none;
|
||||
|
||||
Reference in New Issue
Block a user