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