Fix Stuffbox popup completion and preflight storage before file selection

Hop-State: A_06FP87Y83XVM00YY2RB0X2G
Hop-Proposal: R_06FP87XN1TX5B0CW1ZFDEK8
Hop-Task: T_06FP86NZGXSMEX63FZDAS70
Hop-Attempt: AT_06FP86NZGYZEGPH937G3WK8
This commit is contained in:
2026-07-14 21:53:52 -07:00
committed by Hop
parent 270d5437f0
commit 67b96d6725
9 changed files with 224 additions and 70 deletions
+38 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import AutoTextarea from '@/components/AutoTextarea';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
@@ -8,7 +8,7 @@ import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPro
import { useToast } from '@/lib/contexts/ToastContext';
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
import { refreshStorageSession } from '@/lib/storage/client';
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
export default function AdminPage() {
const { showToast } = useToast();
@@ -30,6 +30,7 @@ export default function AdminPage() {
});
const [savingSettings, setSavingSettings] = useState(false);
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false);
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = useState(false);
@@ -41,6 +42,7 @@ export default function AdminPage() {
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch('/api/admin/me')
.then((res) => res.json())
@@ -145,6 +147,23 @@ export default function AdminPage() {
await uploadBannerFile(file);
};
const handleChooseBanner = async () => {
setBannerUploadError(null);
setIsCheckingBannerStorage(true);
try {
const provider = await getStorageProvider();
if (!provider) {
setShowBannerStorageConfiguration(true);
return;
}
bannerInputRef.current?.click();
} catch (error) {
setBannerUploadError(error instanceof Error ? error.message : 'Unable to check media storage');
} finally {
setIsCheckingBannerStorage(false);
}
};
const handleBannerSessionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -418,16 +437,17 @@ export default function AdminPage() {
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Banner image</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
<label className="btn btn-ghost btn-sm">
{isUploadingBanner ? 'Uploading...' : 'Upload banner'}
<input
type="file"
accept="image/*"
onChange={handleBannerUpload}
disabled={isUploadingBanner}
style={{ display: 'none' }}
/>
</label>
<button className="btn btn-ghost btn-sm" type="button" onClick={handleChooseBanner} disabled={isUploadingBanner || isCheckingBannerStorage}>
{isUploadingBanner ? 'Uploading...' : isCheckingBannerStorage ? 'Checking storage…' : 'Upload banner'}
</button>
<input
ref={bannerInputRef}
type="file"
accept="image/*"
onChange={handleBannerUpload}
disabled={isUploadingBanner}
style={{ display: 'none' }}
/>
{bannerUploadError && (
<span style={{ fontSize: '12px', color: 'var(--danger)' }}>{bannerUploadError}</span>
)}
@@ -608,7 +628,12 @@ export default function AdminPage() {
open={showBannerStorageConfiguration}
onConfigured={async () => {
setShowBannerStorageConfiguration(false);
if (pendingBannerFile) await uploadBannerFile(pendingBannerFile, false);
if (pendingBannerFile) {
await uploadBannerFile(pendingBannerFile, false);
return;
}
showToast('Stuffbox connected. Choose a banner to continue.', 'success');
bannerInputRef.current?.click();
}}
onCancel={() => {
setShowBannerStorageConfiguration(false);
+2 -12
View File
@@ -3,20 +3,10 @@ import { requireAuth } from '@/lib/auth';
import { exchangeAuthorizationCode, StuffboxApiError } from '@/lib/stuffbox/client';
import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response';
function popupResponse(origin: string, success: boolean, message: string): NextResponse {
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
const payload = safeJson({ type: 'synapsis:stuffbox', success, message });
const targetOrigin = safeJson(origin);
const fallback = success ? '/settings/storage?stuffbox=connected' : '/settings/storage?stuffbox=error';
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
<p>${success ? 'Stuffbox connected. You can close this window.' : 'Stuffbox could not be connected.'}</p>
<script>
if (window.opener) { window.opener.postMessage(${payload}, ${targetOrigin}); window.close(); }
else { window.location.replace(${safeJson(fallback)}); }
</script>
</body></html>`;
return new NextResponse(html, {
return new NextResponse(renderStuffboxPopupResponse(origin, success, message), {
status: success ? 200 : 400,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
});
+4 -4
View File
@@ -717,6 +717,9 @@ a.btn-primary:visited {
color: var(--foreground-secondary);
cursor: pointer;
transition: all 0.15s ease;
border: none;
padding: 0;
background: transparent;
}
.compose-media-button:hover {
@@ -725,10 +728,7 @@ a.btn-primary:visited {
}
.compose-media-input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
display: none;
}
.compose-media-error {
+47 -14
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import AutoTextarea from '@/components/AutoTextarea';
import { Post, Attachment } from '@/lib/types';
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
@@ -8,7 +8,7 @@ import { VideoEmbed } from '@/components/VideoEmbed';
import { useFormattedHandle } from '@/lib/utils/handle';
import { useAuth } from '@/lib/contexts/AuthContext';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
interface MediaAttachment extends Attachment {
mimeType?: string;
@@ -29,7 +29,9 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isCheckingStorage, setIsCheckingStorage] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [storageNotice, setStorageNotice] = useState<string | null>(null);
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
const [linkPreview, setLinkPreview] = useState<any>(null);
@@ -38,6 +40,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const [isNsfw, setIsNsfw] = useState(false);
const [canPostNsfw, setCanPostNsfw] = useState(false);
const [isNsfwNode, setIsNsfwNode] = useState(false);
const mediaInputRef = useRef<HTMLInputElement>(null);
const maxLength = 600;
const remaining = maxLength - content.length;
const previewMedia = linkPreview?.media?.length
@@ -164,6 +167,24 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
await uploadMediaFiles(files);
};
const handleAddMedia = async () => {
setUploadError(null);
setStorageNotice(null);
setIsCheckingStorage(true);
try {
const provider = await getStorageProvider();
if (!provider) {
setShowStorageConfiguration(true);
return;
}
mediaInputRef.current?.click();
} catch (error) {
setUploadError(error instanceof Error ? error.message : 'Unable to check media storage');
} finally {
setIsCheckingStorage(false);
}
};
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((item) => item.id !== id));
};
@@ -176,7 +197,12 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
setShowStorageConfiguration(false);
const files = pendingStorageFiles;
setPendingStorageFiles([]);
await uploadMediaFiles(files);
if (files.length > 0) {
await uploadMediaFiles(files);
return;
}
setStorageNotice('Stuffbox connected. Choose your media to continue.');
mediaInputRef.current?.click();
}}
onCancel={() => {
setShowStorageConfiguration(false);
@@ -274,6 +300,9 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
{uploadError && (
<div className="compose-media-error">{uploadError}</div>
)}
{storageNotice && (
<div style={{ color: 'var(--success)', fontSize: '13px', marginTop: '8px' }}>{storageNotice}</div>
)}
<div className="compose-footer">
<div className="compose-footer-left">
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
@@ -292,20 +321,24 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
)}
</div>
<div className="compose-actions">
<label
<button
type="button"
className="compose-media-button"
title="Add media"
onClick={handleAddMedia}
disabled={isUploading || isCheckingStorage || attachments.length >= 4}
>
{isUploading ? '...' : <ImageIcon size={20} />}
<input
type="file"
accept="image/*,video/mp4,video/webm,video/quicktime"
multiple
onChange={handleMediaSelect}
disabled={isUploading || attachments.length >= 4}
className="compose-media-input"
/>
</label>
{isUploading || isCheckingStorage ? '...' : <ImageIcon size={20} />}
</button>
<input
ref={mediaInputRef}
type="file"
accept="image/*,video/mp4,video/webm,video/quicktime"
multiple
onChange={handleMediaSelect}
disabled={isUploading || attachments.length >= 4}
className="compose-media-input"
/>
<button
className="btn btn-primary"
onClick={handleSubmit}
+37 -6
View File
@@ -60,21 +60,52 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.authorizationUrl) throw new Error(data.error || 'Unable to connect Stuffbox');
const result = new Promise<void>((resolve, reject) => {
let settled = false;
let channel: BroadcastChannel | null = null;
const timeout = window.setTimeout(() => {
window.removeEventListener('message', receive);
reject(new Error('Stuffbox connection timed out.'));
finish({ type: 'synapsis:stuffbox', success: false, message: 'Stuffbox connection timed out.' });
}, 10 * 60_000);
const popupClosed = window.setInterval(() => {
if (popup.closed) finish({ type: 'synapsis:stuffbox', success: false, message: 'The Stuffbox window was closed before connecting.' });
}, 500);
function cleanup() {
window.clearTimeout(timeout);
window.clearInterval(popupClosed);
window.removeEventListener('message', receive);
window.removeEventListener('storage', receiveStorage);
channel?.close();
}
function finish(data: { type?: string; success?: boolean; message?: string }) {
if (settled || data.type !== 'synapsis:stuffbox') return;
settled = true;
cleanup();
window.focus();
if (data.success) resolve();
else reject(new Error(data.message || 'Stuffbox could not be connected.'));
}
function receive(event: MessageEvent) {
if (event.origin !== window.location.origin || event.data?.type !== 'synapsis:stuffbox') return;
window.clearTimeout(timeout);
window.removeEventListener('message', receive);
if (event.data.success) resolve();
else reject(new Error(event.data.message || 'Stuffbox could not be connected.'));
finish(event.data);
}
function receiveStorage(event: StorageEvent) {
if (event.key !== 'synapsis:stuffbox:result' || !event.newValue) return;
try { finish(JSON.parse(event.newValue)); } catch { /* Ignore unrelated storage values. */ }
}
window.addEventListener('message', receive);
window.addEventListener('storage', receiveStorage);
if ('BroadcastChannel' in window) {
channel = new BroadcastChannel('synapsis:stuffbox');
channel.onmessage = (event) => finish(event.data);
}
});
popup.location.href = data.authorizationUrl;
await result;
if (!popup.closed) popup.close();
await onConfigured();
} catch (connectError) {
if (!popup.closed) popup.close();
+43 -13
View File
@@ -4,7 +4,7 @@ import { useRef, useState } from 'react';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { refreshStorageSession } from '@/lib/storage/client';
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
interface UserStorageImageUploadProps {
label: string;
@@ -29,6 +29,8 @@ export function UserStorageImageUpload({
}: UserStorageImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [isCheckingStorage, setIsCheckingStorage] = useState(false);
const [storageNotice, setStorageNotice] = useState('');
const [showSessionPrompt, setShowSessionPrompt] = useState(false);
const [showConfigurationPrompt, setShowConfigurationPrompt] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
@@ -79,6 +81,24 @@ export function UserStorageImageUpload({
await uploadFile(file);
};
const handleChooseFile = async () => {
setIsCheckingStorage(true);
setStorageNotice('');
onError?.('');
try {
const provider = await getStorageProvider();
if (!provider) {
setShowConfigurationPrompt(true);
return;
}
inputRef.current?.click();
} catch (error) {
onError?.(error instanceof Error ? error.message : 'Unable to check media storage');
} finally {
setIsCheckingStorage(false);
}
};
const handlePromptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -120,17 +140,17 @@ export function UserStorageImageUpload({
{label}
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: isUploading ? 'default' : 'pointer' }}>
{isUploading ? 'Uploading...' : 'Choose File'}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
style={{ display: 'none' }}
/>
</label>
<button className="btn btn-ghost btn-sm" type="button" onClick={handleChooseFile} disabled={isUploading || isCheckingStorage}>
{isUploading ? 'Uploading...' : isCheckingStorage ? 'Checking storage…' : 'Choose File'}
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
style={{ display: 'none' }}
/>
{value && (
<div
@@ -168,6 +188,11 @@ export function UserStorageImageUpload({
{helperText}
</p>
)}
{storageNotice && (
<p style={{ fontSize: '13px', color: 'var(--success)', marginTop: '6px' }}>
{storageNotice}
</p>
)}
</div>
<StorageSessionPrompt
@@ -186,7 +211,12 @@ export function UserStorageImageUpload({
open={showConfigurationPrompt}
onConfigured={async () => {
setShowConfigurationPrompt(false);
if (pendingFile) await uploadFile(pendingFile, false);
if (pendingFile) {
await uploadFile(pendingFile, false);
return;
}
setStorageNotice('Stuffbox connected. Choose your file to continue.');
inputRef.current?.click();
}}
onCancel={() => {
setShowConfigurationPrompt(false);
+16 -8
View File
@@ -28,10 +28,23 @@ interface JsonResponse extends Record<string, unknown> {
provider?: string | null;
}
export type StorageProvider = 'stuffbox' | 's3' | null;
async function json(response: Response): Promise<JsonResponse> {
return response.json().catch(() => ({})) as Promise<JsonResponse>;
}
export async function getStorageProvider(): Promise<StorageProvider> {
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
const configuration = await json(response);
if (!response.ok) {
throw new MediaUploadError(configuration.error || 'Unable to load storage configuration', undefined, response.status);
}
return configuration.provider === 'stuffbox' || configuration.provider === 's3'
? configuration.provider
: null;
}
function directPut(
uploadUrl: string,
file: File,
@@ -105,13 +118,8 @@ export async function uploadMediaFile(
file: File,
onProgress?: (progress: number) => void,
): Promise<UploadedMedia> {
const configurationResponse = await fetch('/api/storage/configuration', { cache: 'no-store' });
const configuration = await json(configurationResponse);
if (!configurationResponse.ok) {
throw new MediaUploadError(configuration.error || 'Unable to load storage configuration', undefined, configurationResponse.status);
}
if (configuration.provider === 'stuffbox') return uploadToStuffbox(file, onProgress);
if (configuration.provider === 's3') return uploadToS3(file);
const provider = await getStorageProvider();
if (provider === 'stuffbox') return uploadToStuffbox(file, onProgress);
if (provider === 's3') return uploadToS3(file);
throw new MediaUploadError('Connect media storage before uploading.', 'STORAGE_NOT_CONFIGURED', 409);
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { renderStuffboxPopupResponse } from './popup-response';
describe('renderStuffboxPopupResponse', () => {
it('notifies the main tab without navigating the popup into Synapsis', () => {
const html = renderStuffboxPopupResponse('https://synapsis.example', true, 'Stuffbox connected.');
expect(html).toContain("new BroadcastChannel('synapsis:stuffbox')");
expect(html).toContain("localStorage.setItem('synapsis:stuffbox:result'");
expect(html).toContain('window.close()');
expect(html).not.toContain('window.location.replace');
});
});
+24
View File
@@ -0,0 +1,24 @@
export function renderStuffboxPopupResponse(origin: string, success: boolean, message: string): string {
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
const payload = safeJson({ type: 'synapsis:stuffbox', success, message });
const targetOrigin = safeJson(origin);
return `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
<p>${success ? 'Stuffbox connected. Returning to Synapsis…' : 'Stuffbox could not be connected. Returning to Synapsis…'}</p>
<script>
const payload = ${payload};
try {
if (window.opener && !window.opener.closed) window.opener.postMessage(payload, ${targetOrigin});
} catch {}
try {
const channel = new BroadcastChannel('synapsis:stuffbox');
channel.postMessage(payload);
channel.close();
} catch {}
try {
localStorage.setItem('synapsis:stuffbox:result', JSON.stringify({ ...payload, sentAt: Date.now() }));
} catch {}
window.setTimeout(() => window.close(), 75);
</script>
</body></html>`;
}