diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4253309..7fc73eb 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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(null); const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false); const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = useState(false); @@ -41,6 +42,7 @@ export default function AdminPage() { const [logoUploadError, setLogoUploadError] = useState(null); const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); const [faviconUploadError, setFaviconUploadError] = useState(null); + const bannerInputRef = useRef(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) => { event.preventDefault(); @@ -418,16 +437,17 @@ export default function AdminPage() {
- + + {bannerUploadError && ( {bannerUploadError} )} @@ -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); diff --git a/src/app/api/storage/stuffbox/callback/route.ts b/src/app/api/storage/stuffbox/callback/route.ts index 2180c2e..e09f928 100644 --- a/src/app/api/storage/stuffbox/callback/route.ts +++ b/src/app/api/storage/stuffbox/callback/route.ts @@ -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(/Stuffbox -

${success ? 'Stuffbox connected. You can close this window.' : 'Stuffbox could not be connected.'}

- - `; - 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' }, }); diff --git a/src/app/globals.css b/src/app/globals.css index 86741bb..34bce3d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index 5a8051c..1023f31 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -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([]); const [isUploading, setIsUploading] = useState(false); + const [isCheckingStorage, setIsCheckingStorage] = useState(false); const [uploadError, setUploadError] = useState(null); + const [storageNotice, setStorageNotice] = useState(null); const [pendingStorageFiles, setPendingStorageFiles] = useState([]); const [showStorageConfiguration, setShowStorageConfiguration] = useState(false); const [linkPreview, setLinkPreview] = useState(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(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 && (
{uploadError}
)} + {storageNotice && ( +
{storageNotice}
+ )}
@@ -292,20 +321,24 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What )}
- + {isUploading || isCheckingStorage ? '...' : } + + = 4} + className="compose-media-input" + /> + {value && (
)} + {storageNotice && ( +

+ {storageNotice} +

+ )}
{ 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); diff --git a/src/lib/stuffbox/browser-upload.ts b/src/lib/stuffbox/browser-upload.ts index c3be290..3296b34 100644 --- a/src/lib/stuffbox/browser-upload.ts +++ b/src/lib/stuffbox/browser-upload.ts @@ -28,10 +28,23 @@ interface JsonResponse extends Record { provider?: string | null; } +export type StorageProvider = 'stuffbox' | 's3' | null; + async function json(response: Response): Promise { return response.json().catch(() => ({})) as Promise; } +export async function getStorageProvider(): Promise { + 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 { - 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); } diff --git a/src/lib/stuffbox/popup-response.test.ts b/src/lib/stuffbox/popup-response.test.ts new file mode 100644 index 0000000..7e83a97 --- /dev/null +++ b/src/lib/stuffbox/popup-response.test.ts @@ -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'); + }); +}); diff --git a/src/lib/stuffbox/popup-response.ts b/src/lib/stuffbox/popup-response.ts new file mode 100644 index 0000000..a285409 --- /dev/null +++ b/src/lib/stuffbox/popup-response.ts @@ -0,0 +1,24 @@ +export function renderStuffboxPopupResponse(origin: string, success: boolean, message: string): string { + const safeJson = (value: unknown) => JSON.stringify(value).replace(/Stuffbox +

${success ? 'Stuffbox connected. Returning to Synapsis…' : 'Stuffbox could not be connected. Returning to Synapsis…'}

+ + `; +}