diff --git a/src/server/index.js b/src/server/index.js
index b5b03f1..917e241 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -309,6 +309,12 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, ...parsed });
}
+ if (req.method === 'POST' && req.url === '/api/subscription/validate') {
+ const { url = '' } = await readBody(req);
+ const parsed = await fetchSubscription(String(url).trim());
+ return sendJson(res, 200, { success: true, servers: parsed.servers.length });
+ }
+
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
return sendJson(res, 200, await refreshSavedSubscription());
}
diff --git a/src/web/App.jsx b/src/web/App.jsx
index fa7b383..0878343 100644
--- a/src/web/App.jsx
+++ b/src/web/App.jsx
@@ -5,6 +5,7 @@ import { api } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
function App() {
+ const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
@@ -86,13 +87,13 @@ function App() {
request('/api/state'),
subscription: {
+ validate: (url, signal) => request('/api/subscription/validate', {
+ method: 'POST',
+ body: JSON.stringify({ url }),
+ signal,
+ }),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 6afd5c9..e37e597 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -13,6 +13,8 @@ import {
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
+const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
+
function InstructionStep({ step }) {
if (typeof step === 'string') return step;
return (
@@ -76,11 +78,15 @@ function HarborBrand({ isGateway }) {
return (
-
-
Harbor {product}
+
+
+
Harbor {product}
+
);
}
@@ -110,6 +116,9 @@ export function ClientOverviewPage({
const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState('digital');
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
+ const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
+ const [showIntro, setShowIntro] = useState(!hasSubscription);
+ const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({});
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
@@ -124,6 +133,7 @@ export function ClientOverviewPage({
const copyTimerRef = useRef(null);
const instructionsPanelRef = useRef(null);
const instructionsToggleRef = useRef(null);
+ const previousHasSubscriptionRef = useRef(hasSubscription);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
@@ -143,6 +153,11 @@ export function ClientOverviewPage({
const orderedInstructionGuides = openInstruction
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
: instructionGuides;
+ const normalizedSubscriptionUrl = subscriptionUrl.trim();
+ const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
+ ? subscriptionValidation.status
+ : normalizedSubscriptionUrl ? 'checking' : 'idle';
+ const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
useEffect(() => {
setNow(Date.now());
@@ -181,6 +196,57 @@ export function ClientOverviewPage({
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
+ useEffect(() => {
+ if (!showIntro) return undefined;
+ const timer = setTimeout(() => setShowIntro(false), 1200);
+ return () => clearTimeout(timer);
+ }, [showIntro]);
+
+ useEffect(() => {
+ const previouslyHadSubscription = previousHasSubscriptionRef.current;
+ previousHasSubscriptionRef.current = hasSubscription;
+
+ if (!hasSubscription) {
+ setSubscriptionContentReady(false);
+ return undefined;
+ }
+ if (previouslyHadSubscription) {
+ setSubscriptionContentReady(true);
+ return undefined;
+ }
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ setSubscriptionContentReady(true);
+ return undefined;
+ }
+
+ const timer = setTimeout(() => setSubscriptionContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, [hasSubscription]);
+
+ useEffect(() => {
+ if (!normalizedSubscriptionUrl) {
+ setSubscriptionValidation({ url: '', status: 'idle' });
+ return undefined;
+ }
+
+ const controller = new AbortController();
+ setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
+ const timer = setTimeout(() => {
+ api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
+ .then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' }))
+ .catch(() => {
+ if (!controller.signal.aborted) {
+ setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' });
+ }
+ });
+ }, 350);
+
+ return () => {
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [normalizedSubscriptionUrl]);
+
useEffect(() => {
if (!hasSubscription) {
setEditingSubscription(true);
@@ -263,7 +329,7 @@ export function ClientOverviewPage({
async function submitSubscription(event) {
event.preventDefault();
- if (!subscriptionUrl.trim()) return;
+ if (subscriptionValidationStatus !== 'valid') return;
await onFetchSubscription();
setSubscriptionUrl('');
setEditingSubscription(false);
@@ -319,9 +385,9 @@ export function ClientOverviewPage({
}
return (
-
+
- {hasSubscription &&