Polish client duration and tooltip interactions
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>Harbor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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(() => {
|
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]);
|
}, [state?.mode]);
|
||||||
|
|
||||||
async function run(action) {
|
async function run(action) {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import { formatBytes } from '../utils/format.js';
|
|||||||
import { instructionBlocks } from '../instructions.js';
|
import { instructionBlocks } from '../instructions.js';
|
||||||
|
|
||||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
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 }) {
|
function InstructionStep({ step }) {
|
||||||
if (typeof step === 'string') return step;
|
if (typeof step === 'string') return step;
|
||||||
@@ -66,7 +71,7 @@ function InstructionBlock({ block, open, onToggle }) {
|
|||||||
function DurationPart({ name, children }) {
|
function DurationPart({ name, children }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
@@ -176,7 +181,13 @@ export function ClientOverviewPage({
|
|||||||
const showPower = hasSubscription && Boolean(selectedTag);
|
const showPower = hasSubscription && Boolean(selectedTag);
|
||||||
const canStart = Boolean(selectedTag || state?.configExists);
|
const canStart = Boolean(selectedTag || state?.configExists);
|
||||||
const [now, setNow] = useState(Date.now());
|
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 [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
||||||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||||
@@ -449,7 +460,15 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleDurationMode() {
|
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 (
|
return (
|
||||||
@@ -502,11 +521,10 @@ export function ClientOverviewPage({
|
|||||||
<div className="client-state-detail">
|
<div className="client-state-detail">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<button
|
<button
|
||||||
className={`client-duration-toggle${durationMode === 'words' ? ' is-words' : ''}`}
|
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
key="duration"
|
key="duration"
|
||||||
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||||
title={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
|
||||||
onClick={toggleDurationMode}
|
onClick={toggleDurationMode}
|
||||||
>
|
>
|
||||||
<span className="client-duration-stack">
|
<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="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||||
:<DurationPart name="minutes-value">{String(duration.minutes.value).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>
|
||||||
<time
|
<time
|
||||||
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
||||||
@@ -531,12 +549,13 @@ export function ClientOverviewPage({
|
|||||||
.filter(([name, part]) => part.value || name === 'seconds')
|
.filter(([name, part]) => part.value || name === 'seconds')
|
||||||
.map(([name, part]) => (
|
.map(([name, part]) => (
|
||||||
<span className="client-duration-unit" data-unit={name} key={name}>
|
<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>
|
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</time>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
|
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p key="hint">
|
<p key="hint">
|
||||||
@@ -651,22 +670,21 @@ export function ClientOverviewPage({
|
|||||||
<div className="client-subscription-heading">
|
<div className="client-subscription-heading">
|
||||||
<span>Ваша подписка</span>
|
<span>Ваша подписка</span>
|
||||||
<button
|
<button
|
||||||
className="client-subscription-refresh"
|
className="client-subscription-refresh client-tooltip-anchor"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Обновить подписку"
|
aria-label="Обновить подписку"
|
||||||
title="Обновить подписку"
|
|
||||||
disabled={refreshingInfo}
|
disabled={refreshingInfo}
|
||||||
onClick={refreshSubscription}
|
onClick={refreshSubscription}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
<CloudTooltip>Обновить подписку</CloudTooltip>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="client-subscription-delete"
|
className="client-subscription-delete client-tooltip-anchor"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Удалить подписку"
|
aria-label="Удалить подписку"
|
||||||
title="Удалить подписку"
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setConfirmingDelete(true)}
|
onClick={() => setConfirmingDelete(true)}
|
||||||
>
|
>
|
||||||
@@ -674,6 +692,7 @@ export function ClientOverviewPage({
|
|||||||
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
|
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
|
||||||
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
|
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<CloudTooltip>Удалить подписку</CloudTooltip>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ p {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
border-radius: 5px;
|
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);
|
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
|
||||||
backdrop-filter: blur(8px) saturate(0.9);
|
backdrop-filter: blur(8px) saturate(0.9);
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
@@ -359,7 +359,7 @@ p {
|
|||||||
filter: blur(2px);
|
filter: blur(2px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(3px);
|
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 {
|
.harbor-mode-tooltip strong {
|
||||||
@@ -373,7 +373,7 @@ p {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
transition-delay: 120ms, 120ms, 120ms, 0s;
|
transition-delay: 20ms, 20ms, 20ms, 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.harbor-brand-control:hover .harbor-mode-swap,
|
.harbor-brand-control:hover .harbor-mode-swap,
|
||||||
@@ -968,6 +968,16 @@ p {
|
|||||||
content: ' ';
|
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 {
|
.client-duration-toggle {
|
||||||
width: 290px;
|
width: 290px;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
@@ -990,6 +1000,44 @@ p {
|
|||||||
outline: none;
|
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 {
|
.client-power-section p {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
@@ -1855,6 +1903,7 @@ p {
|
|||||||
.client-access-point,
|
.client-access-point,
|
||||||
.client-copy-feedback,
|
.client-copy-feedback,
|
||||||
.client-duration,
|
.client-duration,
|
||||||
|
.client-duration-seconds-value,
|
||||||
.client-instructions,
|
.client-instructions,
|
||||||
.client-instructions-toggle,
|
.client-instructions-toggle,
|
||||||
.client-instructions-toggle svg,
|
.client-instructions-toggle svg,
|
||||||
@@ -1899,6 +1948,10 @@ p {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-tooltip {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.client-state-copy h2,
|
.client-state-copy h2,
|
||||||
.client-state-detail > * {
|
.client-state-detail > * {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user