diff --git a/src/app/globals.css b/src/app/globals.css index 68dcbac..9fdd2ab 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -860,6 +860,127 @@ a.btn-primary:visited { font-size: 13px; } +/* Global post composer */ +.global-compose-fab { + position: fixed; + right: max(24px, env(safe-area-inset-right)); + bottom: max(24px, env(safe-area-inset-bottom)); + z-index: 900; + display: grid; + place-items: center; + width: 58px; + height: 58px; + min-height: 58px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--background); + background: var(--accent); + box-shadow: 0 12px 34px color-mix(in srgb, var(--accent) 28%, rgba(0, 0, 0, 0.55)); + cursor: pointer; + transition: transform 0.18s ease, filter 0.18s ease, box-shadow 0.18s ease; +} + +.global-compose-fab:hover { + filter: brightness(1.08); + transform: translateY(-2px) scale(1.03); +} + +.global-compose-fab:active { + transform: scale(0.96); +} + +.global-compose-overlay { + position: fixed; + inset: 0; + z-index: 11000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + animation: globalComposeFadeIn 0.16s ease-out; +} + +.global-compose-modal { + width: min(640px, 100%); + max-height: calc(100dvh - 48px); + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--background-secondary); + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.65); + animation: globalComposeRiseIn 0.2s ease-out; +} + +.global-compose-header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--background-secondary) 94%, transparent); + backdrop-filter: blur(14px); +} + +.global-compose-header h2 { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.global-compose-close { + display: grid; + place-items: center; + width: 36px; + height: 36px; + min-height: 36px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--foreground-secondary); + background: transparent; + cursor: pointer; +} + +.global-compose-close:hover { + color: var(--foreground); + background: var(--background-tertiary); +} + +@keyframes globalComposeFadeIn { + from { opacity: 0; } +} + +@keyframes globalComposeRiseIn { + from { opacity: 0; transform: translateY(12px) scale(0.985); } +} + +@media (max-width: 768px) { + .global-compose-fab { + right: 18px; + bottom: calc(76px + env(safe-area-inset-bottom)); + width: 54px; + height: 54px; + min-height: 54px; + } + + .global-compose-overlay { + align-items: end; + padding: 12px; + } + + .global-compose-modal { + width: 100%; + max-height: calc(100dvh - 24px); + border-radius: 22px; + } +} + /* Feed toggle + meta */ .feed-toggle { display: inline-flex; diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index 7e66ebc..bc3f8e7 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -17,14 +17,16 @@ interface MediaAttachment extends Attachment { } interface ComposeProps { - onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void; + onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise; + onPosted?: () => void; replyingTo?: Post | null; onCancelReply?: () => void; placeholder?: string; isReply?: boolean; + autoFocus?: boolean; } -export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) { +export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) { const { isIdentityUnlocked } = useAuth(); const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : ''; const [content, setContent] = useState(''); @@ -116,13 +118,24 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What } setIsPosting(true); - await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw); - setContent(''); - setAttachments([]); - setLinkPreview(null); - setLastDetectedUrl(null); - setIsNsfw(false); - setIsPosting(false); + try { + const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw); + if (posted === false) { + setIsPosting(false); + return; + } + + setContent(''); + setAttachments([]); + setLinkPreview(null); + setLastDetectedUrl(null); + setIsNsfw(false); + setIsPosting(false); + onPosted?.(); + } catch (error) { + setIsPosting(false); + throw error; + } }; const uploadMediaFiles = async (files: File[]) => { @@ -228,6 +241,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What value={content} onChange={(e) => setContent(e.target.value)} maxLength={maxLength + 50} // Allow some overflow for better UX + autoFocus={autoFocus} /> {attachments.length > 0 && (
diff --git a/src/components/GlobalPostComposer.tsx b/src/components/GlobalPostComposer.tsx new file mode 100644 index 0000000..45c2861 --- /dev/null +++ b/src/components/GlobalPostComposer.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Plus, X } from 'lucide-react'; +import { Compose } from '@/components/Compose'; +import { signedAPI } from '@/lib/api/signed-fetch'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { useToast } from '@/lib/contexts/ToastContext'; + +export function GlobalPostComposer() { + const { user, did, handle } = useAuth(); + const { showToast } = useToast(); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + window.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + useEffect(() => { + if (!user) setOpen(false); + }, [user]); + + if (!user) return null; + + const handlePost = async ( + content: string, + mediaIds: string[], + linkPreview?: unknown, + _replyToId?: string, + isNsfw?: boolean, + ): Promise => { + if (!did || !handle) { + showToast('Your identity is still loading. Try again in a moment.', 'error'); + return false; + } + + try { + const response = await signedAPI.createPost( + content, + mediaIds, + linkPreview, + undefined, + undefined, + isNsfw || false, + did, + handle, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + showToast(data.error || 'Failed to publish post.', 'error'); + return false; + } + + return true; + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to publish post.', 'error'); + return false; + } + }; + + return ( + <> + {!open && ( + + )} + + {open && ( +
{ + if (event.target === event.currentTarget) setOpen(false); + }} + > +
+
+

Create post

+ +
+ { + setOpen(false); + showToast('Post published.', 'success'); + }} + /> +
+
+ )} + + ); +} diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx index ee887fe..8ce253e 100644 --- a/src/components/LayoutWrapper.tsx +++ b/src/components/LayoutWrapper.tsx @@ -6,6 +6,7 @@ import { RightSidebar } from './RightSidebar'; import { useAuth } from '@/lib/contexts/AuthContext'; import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; import { isAppBootstrapReady } from '@/lib/bootstrap/readiness'; +import { GlobalPostComposer } from './GlobalPostComposer'; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const { loading } = useAuth(); @@ -58,6 +59,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { {children} {!hideRightSidebar && } +
);