Gate storage settings rendering until account storage status is ready
Hop-State: A_06FPCTGM3Q2ZPDN6C6BPVQR Hop-Proposal: R_06FPCTEQFP5N8419897F3QG Hop-Task: T_06FPCSTHZSG7HGC71RC8FNR Hop-Attempt: AT_06FPCSTHZRZPFGE0XP75VKR
This commit is contained in:
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Box, ExternalLink } from 'lucide-react';
|
import { Box, ExternalLink, Loader2 } from 'lucide-react';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||||
|
import { getStoragePageState } from './storage-page-state';
|
||||||
|
|
||||||
interface StorageStatus {
|
interface StorageStatus {
|
||||||
provider: 'stuffbox' | null;
|
provider: 'stuffbox' | null;
|
||||||
@@ -15,9 +16,11 @@ interface StorageStatus {
|
|||||||
export default function StorageSettingsPage() {
|
export default function StorageSettingsPage() {
|
||||||
const [status, setStatus] = useState<StorageStatus | null>(null);
|
const [status, setStatus] = useState<StorageStatus | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async (showLoading = false) => {
|
||||||
|
if (showLoading) setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
|
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
@@ -26,10 +29,12 @@ export default function StorageSettingsPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load storage settings');
|
setError(loadError instanceof Error ? loadError.message : 'Unable to load storage settings');
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
useEffect(() => { void loadStatus(true); }, [loadStatus]);
|
||||||
|
|
||||||
const disconnectStuffbox = async () => {
|
const disconnectStuffbox = async () => {
|
||||||
if (!window.confirm('Disconnect Stuffbox from this Synapsis account? Existing media links will keep working.')) return;
|
if (!window.confirm('Disconnect Stuffbox from this Synapsis account? Existing media links will keep working.')) return;
|
||||||
@@ -47,6 +52,19 @@ export default function StorageSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pageState = getStoragePageState(status, isLoading);
|
||||||
|
|
||||||
|
if (pageState === 'loading') {
|
||||||
|
return (
|
||||||
|
<main aria-busy="true" aria-label="Loading media storage" style={{ minHeight: '70vh', display: 'grid', placeItems: 'center', padding: '24px' }}>
|
||||||
|
<div role="status" style={{ display: 'flex', alignItems: 'center', gap: '10px', color: 'var(--foreground-secondary)' }}>
|
||||||
|
<Loader2 className="animate-spin" size={22} aria-hidden="true" />
|
||||||
|
<span>Loading media storage…</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
|
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
|
||||||
@@ -65,7 +83,7 @@ export default function StorageSettingsPage() {
|
|||||||
Storage belongs to your account rather than this node. That keeps your media available if you move your account elsewhere.
|
Storage belongs to your account rather than this node. That keeps your media available if you move your account elsewhere.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
{status && <div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||||
<Box size={22} />
|
<Box size={22} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -82,9 +100,9 @@ export default function StorageSettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{status?.provider !== 'stuffbox' && (
|
{pageState === 'disconnected' && status && (
|
||||||
<div className="card" style={{ padding: '20px' }}>
|
<div className="card" style={{ padding: '20px' }}>
|
||||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
|
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
|
||||||
Connect Stuffbox
|
Connect Stuffbox
|
||||||
@@ -92,7 +110,13 @@ export default function StorageSettingsPage() {
|
|||||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}>
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}>
|
||||||
Connect the official Synapsis storage partner to upload portable media from your account.
|
Connect the official Synapsis storage partner to upload portable media from your account.
|
||||||
</p>
|
</p>
|
||||||
<StorageConfigurationPrompt open onConfigured={loadStatus} onCancel={() => {}} variant="inline" />
|
<StorageConfigurationPrompt
|
||||||
|
open
|
||||||
|
stuffboxAvailable={status.stuffboxAvailable}
|
||||||
|
onConfigured={loadStatus}
|
||||||
|
onCancel={() => {}}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p style={{ color: 'var(--error)', fontSize: '14px', marginTop: '14px' }}>{error}</p>}
|
{error && <p style={{ color: 'var(--error)', fontSize: '14px', marginTop: '14px' }}>{error}</p>}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getStoragePageState } from './storage-page-state';
|
||||||
|
|
||||||
|
describe('storage page render gate', () => {
|
||||||
|
it('never treats missing initial status as a disconnected account', () => {
|
||||||
|
expect(getStoragePageState(null, true)).toBe('loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the connection UI only after a disconnected status is loaded', () => {
|
||||||
|
expect(getStoragePageState({ provider: null }, false)).toBe('disconnected');
|
||||||
|
expect(getStoragePageState({ provider: 'stuffbox' }, false)).toBe('connected');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface StoragePageStatus {
|
||||||
|
provider: 'stuffbox' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoragePageState = 'loading' | 'error' | 'connected' | 'disconnected';
|
||||||
|
|
||||||
|
export function getStoragePageState(
|
||||||
|
status: StoragePageStatus | null,
|
||||||
|
isLoading: boolean,
|
||||||
|
): StoragePageState {
|
||||||
|
if (isLoading) return 'loading';
|
||||||
|
if (!status) return 'error';
|
||||||
|
return status.provider === 'stuffbox' ? 'connected' : 'disconnected';
|
||||||
|
}
|
||||||
@@ -14,12 +14,13 @@ interface StorageConfigurationPromptProps {
|
|||||||
onConfigured: () => void | Promise<void>;
|
onConfigured: () => void | Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
variant?: 'modal' | 'inline';
|
variant?: 'modal' | 'inline';
|
||||||
|
stuffboxAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageConfigurationPrompt({ open, onConfigured, onCancel, variant = 'modal' }: StorageConfigurationPromptProps) {
|
export function StorageConfigurationPrompt({ open, onConfigured, onCancel, variant = 'modal', stuffboxAvailable: prefetchedStuffboxAvailable }: StorageConfigurationPromptProps) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [stuffboxAvailable, setStuffboxAvailable] = useState(false);
|
const [stuffboxAvailable, setStuffboxAvailable] = useState(prefetchedStuffboxAvailable ?? false);
|
||||||
const [isConnectingStuffbox, setIsConnectingStuffbox] = useState(false);
|
const [isConnectingStuffbox, setIsConnectingStuffbox] = useState(false);
|
||||||
const connectionAbortRef = useRef<AbortController | null>(null);
|
const connectionAbortRef = useRef<AbortController | null>(null);
|
||||||
const connectionPopupRef = useRef<Window | null>(null);
|
const connectionPopupRef = useRef<Window | null>(null);
|
||||||
@@ -51,6 +52,12 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
if (prefetchedStuffboxAvailable !== undefined) {
|
||||||
|
setError('');
|
||||||
|
setStuffboxAvailable(prefetchedStuffboxAvailable);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
setError('');
|
setError('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -63,7 +70,7 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
.catch((loadError) => active && setError(loadError instanceof Error ? loadError.message : 'Unable to load storage options'))
|
.catch((loadError) => active && setError(loadError instanceof Error ? loadError.message : 'Unable to load storage options'))
|
||||||
.finally(() => active && setIsLoading(false));
|
.finally(() => active && setIsLoading(false));
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [open]);
|
}, [open, prefetchedStuffboxAvailable]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
const connectStuffbox = async () => {
|
const connectStuffbox = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user