Restyle Media Storage settings and render storage configuration inline

Hop-State: A_06FP83QXM2N0A8C2AXNC3X0
Hop-Proposal: R_06FP83QBS4MX9A1YANYTZ48
Hop-Task: T_06FP8311WS7RGCSMMDQFWB8
Hop-Attempt: AT_06FP8311WT376768DB2VXGR
This commit is contained in:
2026-07-14 21:35:31 -07:00
committed by Hop
parent 9f0bfb59ab
commit 25122a8ebd
2 changed files with 64 additions and 33 deletions
+34 -18
View File
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Box, Cloud, HardDrive } from 'lucide-react'; import { Box, Cloud, HardDrive } from 'lucide-react';
import { ArrowLeftIcon } from '@/components/Icons';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
interface StorageStatus { interface StorageStatus {
@@ -15,7 +16,6 @@ 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 [showConnect, setShowConnect] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
@@ -48,19 +48,25 @@ export default function StorageSettingsPage() {
} }
}; };
return <> return (
<header style={{ padding: '16px', borderBottom: '1px solid var(--border)', position: 'sticky', top: 0, background: 'var(--background)', zIndex: 10 }}> <div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings" className="btn btn-ghost btn-sm" aria-label="Back to settings"><ArrowLeft size={18} /></Link> <Link href="/settings" style={{ color: 'var(--foreground)' }} aria-label="Back to settings">
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Media Storage</h1> <ArrowLeftIcon />
</div> </Link>
</header> <div>
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}> <h1 style={{ fontSize: '24px', fontWeight: 700 }}>Media Storage</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage where your account stores uploaded media
</p>
</div>
</header>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}> <p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
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' }}> <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' }}>
{status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />} {status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -69,13 +75,23 @@ export default function StorageSettingsPage() {
{status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>} {status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>}
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '10px', marginTop: '18px', flexWrap: 'wrap' }}> {status?.provider === 'stuffbox' && (
<button className="btn btn-primary" type="button" onClick={() => setShowConnect(true)} disabled={!status}>{status?.provider ? 'Change storage' : 'Connect storage'}</button> <button className="btn btn-ghost" type="button" onClick={disconnectStuffbox} disabled={isDisconnecting} style={{ marginTop: '18px' }}>
{status?.provider === 'stuffbox' && <button className="btn btn-ghost" type="button" onClick={disconnectStuffbox} disabled={isDisconnecting}>{isDisconnecting ? 'Disconnecting…' : 'Disconnect Stuffbox'}</button>} {isDisconnecting ? 'Disconnecting…' : 'Disconnect Stuffbox'}
</div> </button>
)}
</div>
<div className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
{status?.provider ? 'Change storage' : 'Connect media storage'}
</h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}>
Choose Stuffbox for the simplest setup, or connect an S3-compatible bucket you already own.
</p>
<StorageConfigurationPrompt open 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>}
</main> </div>
<StorageConfigurationPrompt open={showConnect} onConfigured={async () => { setShowConnect(false); await loadStatus(); }} onCancel={() => setShowConnect(false)} /> );
</>;
} }
+30 -15
View File
@@ -7,9 +7,10 @@ interface StorageConfigurationPromptProps {
open: boolean; open: boolean;
onConfigured: () => void | Promise<void>; onConfigured: () => void | Promise<void>;
onCancel: () => void; onCancel: () => void;
variant?: 'modal' | 'inline';
} }
export function StorageConfigurationPrompt({ open, onConfigured, onCancel }: StorageConfigurationPromptProps) { export function StorageConfigurationPrompt({ open, onConfigured, onCancel, variant = 'modal' }: StorageConfigurationPromptProps) {
const [provider, setProvider] = useState('r2'); const [provider, setProvider] = useState('r2');
const [endpoint, setEndpoint] = useState(''); const [endpoint, setEndpoint] = useState('');
const [publicBaseUrl, setPublicBaseUrl] = useState(''); const [publicBaseUrl, setPublicBaseUrl] = useState('');
@@ -103,35 +104,38 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel }: Sto
} }
}; };
return ( const content = (
<div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={onCancel}> <>
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}> {variant === 'modal' && (
<>
<h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect media storage</h3> <h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect media storage</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}> <p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
Your media lives outside this Synapsis node, so your account stays portable. Your media lives outside this Synapsis node, so your account stays portable.
</p> </p>
</>
)}
<div style={{ border: '1px solid var(--border)', borderRadius: '12px', padding: '18px', background: 'var(--background-secondary)' }}> <div style={{ border: '1px solid var(--border)', borderRadius: '12px', padding: '18px', background: 'var(--background-secondary)' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
<Box size={24} style={{ flex: '0 0 auto', marginTop: '2px' }} /> <Box size={24} style={{ flex: '0 0 auto', marginTop: '2px' }} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>Stuffbox</div> <div style={{ fontWeight: 600, marginBottom: '4px' }}>Stuffbox</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.45 }}> <div style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.45 }}>
Connect once, then upload directly from your browser. Synapsis never sees your storage credentials. Connect once, then uploads just work. You own your media and can take it anywhere.
</div> </div>
</div> </div>
</div> </div>
<button type="button" className="btn btn-primary" onClick={connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}> <button type="button" className="btn btn-primary" onClick={connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}>
{isSubmitting && !showS3 ? 'Connecting…' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'} {isSubmitting && !showS3 ? 'Connecting…' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'}
</button> </button>
</div> </div>
<button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}> <button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}>
Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} /> Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} />
</button> </button>
{showS3 && ( {showS3 && (
<form onSubmit={connectS3} style={{ marginTop: '16px' }}> <form onSubmit={connectS3} style={{ marginTop: '16px' }}>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', lineHeight: 1.45, marginBottom: '14px' }}> <p style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', lineHeight: 1.45, marginBottom: '14px' }}>
Advanced option. Credentials are encrypted locally; you may need to confirm your password again after the node restarts. Advanced option. Credentials are encrypted locally; you may need to confirm your password again after the node restarts.
</p> </p>
@@ -153,11 +157,22 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel }: Sto
<Field label="Secret access key" value={secretKey} onChange={setSecretKey} type="password" minLength={10} /> <Field label="Secret access key" value={secretKey} onChange={setSecretKey} type="password" minLength={10} />
<Field label="Synapsis account password" value={password} onChange={setPassword} type="password" /> <Field label="Synapsis account password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="btn btn-primary" disabled={isSubmitting} style={{ width: '100%' }}>{isSubmitting ? 'Connecting…' : 'Connect S3 storage'}</button> <button type="submit" className="btn btn-primary" disabled={isSubmitting} style={{ width: '100%' }}>{isSubmitting ? 'Connecting…' : 'Connect S3 storage'}</button>
</form> </form>
)} )}
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>} {error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
{variant === 'modal' && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button></div> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button></div>
)}
</>
);
if (variant === 'inline') return content;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={onCancel}>
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}>
{content}
</div> </div>
</div> </div>
); );