feat: Introduce runtime configuration for domain and enhance S3 storage settings with public base URL and Contabo support.
This commit is contained in:
@@ -72,6 +72,9 @@ services:
|
||||
# Port configuration (auto or specific port)
|
||||
PORT: ${PORT:-3000}
|
||||
|
||||
# S3 Storage public URL (for custom S3 providers like R2, B2, Contabo)
|
||||
STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-}
|
||||
|
||||
# Node environment
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
|
||||
@@ -15,6 +15,7 @@ const registerSchema = z.object({
|
||||
// S3-compatible storage credentials
|
||||
storageProvider: z.string().min(1),
|
||||
storageEndpoint: z.string().nullable().optional(),
|
||||
storagePublicBaseUrl: z.string().nullable().optional(),
|
||||
storageRegion: z.string().min(1),
|
||||
storageBucket: z.string().min(1),
|
||||
storageAccessKey: z.string().min(10),
|
||||
@@ -71,6 +72,7 @@ export async function POST(request: Request) {
|
||||
data.displayName,
|
||||
data.storageProvider,
|
||||
data.storageEndpoint || null,
|
||||
data.storagePublicBaseUrl || null,
|
||||
data.storageRegion,
|
||||
data.storageBucket,
|
||||
data.storageAccessKey,
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function POST(request: Request) {
|
||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||
botHandle,
|
||||
user.storageEndpoint || undefined,
|
||||
user.storagePublicBaseUrl || undefined,
|
||||
user.storageRegion || 'auto',
|
||||
user.storageBucket,
|
||||
accessKeyId,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:3000',
|
||||
});
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export async function POST(req: NextRequest) {
|
||||
file.type,
|
||||
user.storageProvider as any,
|
||||
user.storageEndpoint,
|
||||
user.storagePublicBaseUrl,
|
||||
user.storageRegion || 'us-east-1',
|
||||
user.storageBucket || '',
|
||||
user.storageAccessKeyEncrypted,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
interface Conversation {
|
||||
@@ -43,6 +43,7 @@ export default function ChatPage() {
|
||||
// Chat Data State
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
const selectedHandle = selectedConversation ? useFormattedHandle(selectedConversation.participant2.handle) : '';
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -389,7 +390,7 @@ export default function ChatPage() {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{formatFullHandle(selectedConversation.participant2.handle)}
|
||||
{selectedHandle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Post } from '@/lib/types';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { Bot, Network, Server, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface User {
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
const fullHandle = useFormattedHandle(user.handle);
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-card">
|
||||
<div className="avatar">
|
||||
@@ -52,7 +53,7 @@ function UserCard({ user }: { user: User }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-card-handle">{formatFullHandle(user.handle)}</div>
|
||||
<div className="user-card-handle">{fullHandle}</div>
|
||||
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
+12
-9
@@ -57,6 +57,7 @@ export const dynamic = 'force-dynamic';
|
||||
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
||||
import { ToastProvider } from '@/lib/contexts/ToastContext';
|
||||
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
|
||||
import { ConfigProvider } from '@/lib/contexts/ConfigContext';
|
||||
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -67,15 +68,17 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<AccentColorProvider>
|
||||
<ToastProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
</ToastProvider>
|
||||
</AccentColorProvider>
|
||||
</AuthProvider>
|
||||
<ConfigProvider>
|
||||
<AuthProvider>
|
||||
<AccentColorProvider>
|
||||
<ToastProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
</ToastProvider>
|
||||
</AccentColorProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+45
-15
@@ -34,6 +34,7 @@ export default function LoginPage() {
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [storageProvider, setStorageProvider] = useState('r2');
|
||||
const [storageEndpoint, setStorageEndpoint] = useState('');
|
||||
const [storagePublicBaseUrl, setStoragePublicBaseUrl] = useState('');
|
||||
const [storageRegion, setStorageRegion] = useState('auto');
|
||||
const [storageBucket, setStorageBucket] = useState('');
|
||||
const [storageAccessKey, setStorageAccessKey] = useState('');
|
||||
@@ -252,6 +253,7 @@ export default function LoginPage() {
|
||||
displayName,
|
||||
storageProvider,
|
||||
storageEndpoint: storageEndpoint || null,
|
||||
storagePublicBaseUrl: storagePublicBaseUrl || null,
|
||||
storageRegion,
|
||||
storageBucket,
|
||||
storageAccessKey,
|
||||
@@ -573,6 +575,7 @@ export default function LoginPage() {
|
||||
<option value="r2">Cloudflare R2 (10GB free)</option>
|
||||
<option value="b2">Backblaze B2 (10GB free)</option>
|
||||
<option value="wasabi">Wasabi</option>
|
||||
<option value="contabo">Contabo S3</option>
|
||||
<option value="s3">AWS S3</option>
|
||||
<option value="minio">MinIO / Self-hosted</option>
|
||||
<option value="other">Other S3-compatible</option>
|
||||
@@ -611,22 +614,49 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* S3 Credentials - Full Width */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Endpoint URL (optional for AWS)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storageEndpoint}
|
||||
onChange={(e) => setStorageEndpoint(e.target.value)}
|
||||
placeholder="https://<account>.r2.cloudflarestorage.com"
|
||||
/>
|
||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Leave empty for AWS S3. Required for R2, B2, MinIO.
|
||||
{/* Endpoint URL - only show for providers that need it (R2, B2, Contabo, MinIO, Other) */}
|
||||
{(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo' || storageProvider === 'minio' || storageProvider === 'other') && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Endpoint URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storageEndpoint}
|
||||
onChange={(e) => setStorageEndpoint(e.target.value)}
|
||||
placeholder={storageProvider === 'contabo' ? 'https://s3.eu2.contabo.com' : 'https://<account>.r2.cloudflarestorage.com'}
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
{storageProvider === 'contabo' ? 'S3 API endpoint from Contabo dashboard (e.g., https://s3.eu2.contabo.com)' :
|
||||
'S3-compatible endpoint URL for uploads.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Public Base URL - only show for providers that need it (R2, B2, Contabo, Other) */}
|
||||
{(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo' || storageProvider === 'other') && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Public Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storagePublicBaseUrl}
|
||||
onChange={(e) => setStoragePublicBaseUrl(e.target.value)}
|
||||
placeholder={storageProvider === 'contabo' ? 'https://usc1.contabostorage.com/d5bec82ae49b444d8314dcb13654dd1d:your-bucket' : 'https://pub-xxx.r2.dev'}
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
{storageProvider === 'contabo' ? 'Public URL from Contabo (format: https://region.contabostorage.com/account-id:bucket)' :
|
||||
storageProvider === 'r2' ? 'Public bucket URL from R2 dashboard (e.g., https://pub-xxx.r2.dev)' :
|
||||
storageProvider === 'b2' ? 'Public bucket URL from B2 dashboard (e.g., https://f000.backblazeb2.com/file/bucket)' :
|
||||
'Public URL where files are accessible (without trailing slash)'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Post } from '@/lib/types';
|
||||
import { Bot } from 'lucide-react';
|
||||
@@ -65,6 +65,7 @@ const FlagIcon = () => (
|
||||
);
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
const fullHandle = useFormattedHandle(user.handle);
|
||||
return (
|
||||
<Link
|
||||
href={`/@${user.handle}`}
|
||||
@@ -107,7 +108,7 @@ function UserCard({ user }: { user: User }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>{formatFullHandle(user.handle)}</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>{fullHandle}</div>
|
||||
{user.bio && (
|
||||
<div style={{
|
||||
color: 'var(--foreground-secondary)',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PostCard } from '@/components/PostCard';
|
||||
import { User, Post } from '@/lib/types';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
@@ -35,6 +35,7 @@ const stripHtml = (html: string | null | undefined): string | null => {
|
||||
};
|
||||
|
||||
function UserRow({ user }: { user: UserSummary }) {
|
||||
const fullHandle = useFormattedHandle(user.handle);
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-row">
|
||||
<div className="avatar">
|
||||
@@ -66,7 +67,7 @@ function UserRow({ user }: { user: UserSummary }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{fullHandle}</div>
|
||||
{user.bio && stripHtml(user.bio) && (
|
||||
<div className="user-row-bio">{stripHtml(user.bio)}</div>
|
||||
)}
|
||||
@@ -82,6 +83,7 @@ export default function ProfilePage() {
|
||||
const { isIdentityUnlocked, signUserAction } = useAuth();
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
||||
@@ -719,7 +721,7 @@ export default function ProfilePage() {
|
||||
{/* User Info */}
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2>
|
||||
<p style={{ color: 'var(--foreground-tertiary)' }}>{formatFullHandle(user.handle)}</p>
|
||||
<p style={{ color: 'var(--foreground-tertiary)' }}>{userFullHandle}</p>
|
||||
|
||||
{user.bio && (
|
||||
<p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Post, Attachment } from '@/lib/types';
|
||||
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
|
||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface MediaAttachment extends Attachment {
|
||||
@@ -22,6 +22,7 @@ interface ComposeProps {
|
||||
|
||||
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
|
||||
const { isIdentityUnlocked } = useAuth();
|
||||
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
|
||||
const [content, setContent] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
||||
@@ -161,7 +162,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
||||
{replyingTo && !isReply && (
|
||||
<div className="compose-reply-target">
|
||||
<div className="compose-reply-info">
|
||||
Replying to <span className="compose-reply-handle">{formatFullHandle(replyingTo.author.handle)}</span>
|
||||
Replying to <span className="compose-reply-handle">{replyToHandle}</span>
|
||||
</div>
|
||||
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
|
||||
Cancel
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||
import BlurredVideo from '@/components/BlurredVideo';
|
||||
import { formatFullHandle, NODE_DOMAIN } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { useDomain } from '@/lib/contexts/ConfigContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
|
||||
// Component for link preview image that hides on error
|
||||
@@ -52,6 +53,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const domain = useDomain();
|
||||
const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
|
||||
const replyToHandle = post.replyTo?.author?.handle ? useFormattedHandle(post.replyTo.author.handle) : '';
|
||||
|
||||
// Sync state if post changes (e.g. after a re-render from parent)
|
||||
useEffect(() => {
|
||||
@@ -263,7 +267,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
return post.author.handle;
|
||||
}
|
||||
// If this is a swarm post from a DIFFERENT node, append the node domain
|
||||
if (post.nodeDomain && post.nodeDomain !== NODE_DOMAIN) {
|
||||
if (post.nodeDomain && post.nodeDomain !== domain) {
|
||||
return `${post.author.handle}@${post.nodeDomain}`;
|
||||
}
|
||||
// Local user
|
||||
@@ -403,7 +407,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||
{post.author.displayName || post.author.handle}
|
||||
</Link>
|
||||
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span>
|
||||
<span className="post-time">{authorHandle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
|
||||
@@ -467,7 +471,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)} · {formatTime(post.createdAt)}</span>
|
||||
<span className="post-time">{authorHandle} · {formatTime(post.createdAt)}</span>
|
||||
</div>
|
||||
{currentUser && currentUser.id !== post.author.id && (
|
||||
<div style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||
@@ -591,7 +595,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
|
||||
{effectiveReplyTo && !showThread && (
|
||||
<div className="post-reply-to">
|
||||
Replying to <Link href={`/u/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link>
|
||||
Replying to <Link href={`/u/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{replyToHandle}</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -672,7 +676,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
// Allow deleting own remote posts where handle might be username@node_domain
|
||||
(post.author.id.startsWith('swarm:') && (
|
||||
post.author.handle === currentUser.handle ||
|
||||
post.author.handle === `${currentUser.handle}@${NODE_DOMAIN}`
|
||||
post.author.handle === `${currentUser.handle}@${domain}`
|
||||
))
|
||||
)) && (
|
||||
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
||||
|
||||
@@ -6,7 +6,7 @@ import Image from 'next/image';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { LogOut, Settings2 } from 'lucide-react';
|
||||
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
|
||||
|
||||
@@ -18,6 +18,7 @@ export function Sidebar() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const formattedHandle = user ? useFormattedHandle(user.handle) : '';
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
@@ -188,7 +189,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatFullHandle(user.handle)}</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formattedHandle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-1
@@ -65,8 +65,9 @@ export const users = pgTable('users', {
|
||||
movedFrom: text('moved_from'), // Old actor URL if this account migrated here
|
||||
migratedAt: timestamp('migrated_at'), // When the migration occurred
|
||||
// User-owned S3-compatible storage - required for new users
|
||||
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', etc
|
||||
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', 'contabo', etc
|
||||
storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS)
|
||||
storagePublicBaseUrl: text('storage_public_base_url'), // Public URL for viewing files (required for R2, B2, Contabo)
|
||||
storageRegion: text('storage_region'), // Region (e.g., 'us-east-1')
|
||||
storageBucket: text('storage_bucket'), // Bucket name
|
||||
storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key
|
||||
|
||||
@@ -139,6 +139,7 @@ export async function registerUser(
|
||||
displayName?: string,
|
||||
storageProvider?: string,
|
||||
storageEndpoint?: string | null,
|
||||
storagePublicBaseUrl?: string | null,
|
||||
storageRegion?: string,
|
||||
storageBucket?: string,
|
||||
storageAccessKey?: string,
|
||||
@@ -201,6 +202,7 @@ export async function registerUser(
|
||||
const avatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||
fullHandle,
|
||||
storageEndpoint || undefined,
|
||||
storagePublicBaseUrl || undefined,
|
||||
storageRegion,
|
||||
storageBucket,
|
||||
storageAccessKey,
|
||||
@@ -222,6 +224,7 @@ export async function registerUser(
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||
storageProvider,
|
||||
storageEndpoint: storageEndpoint || null,
|
||||
storagePublicBaseUrl: storagePublicBaseUrl || null,
|
||||
storageRegion,
|
||||
storageBucket,
|
||||
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
let cachedConfig: { domain: string } | null = null;
|
||||
let configPromise: Promise<{ domain: string }> | null = null;
|
||||
|
||||
export async function getRuntimeConfig() {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
if (configPromise) return configPromise;
|
||||
|
||||
configPromise = fetch('/api/config')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
cachedConfig = {
|
||||
domain: data.domain || 'localhost:3000',
|
||||
};
|
||||
return cachedConfig;
|
||||
})
|
||||
.catch(() => {
|
||||
cachedConfig = {
|
||||
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000',
|
||||
};
|
||||
return cachedConfig;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
export function getDomain(): string {
|
||||
return cachedConfig?.domain || process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
}
|
||||
|
||||
export function clearCachedConfig() {
|
||||
cachedConfig = null;
|
||||
configPromise = null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
|
||||
interface RuntimeConfig {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
interface ConfigContextType {
|
||||
config: RuntimeConfig | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType>({
|
||||
config: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<RuntimeConfig | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch runtime config on mount
|
||||
fetch('/api/config')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setConfig({
|
||||
domain: data.domain || 'localhost:3000',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to build-time value if fetch fails
|
||||
setConfig({
|
||||
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={{ config, isLoading }}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRuntimeConfig() {
|
||||
const context = useContext(ConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useRuntimeConfig must be used within a ConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useDomain(): string {
|
||||
const { config, isLoading } = useRuntimeConfig();
|
||||
// Return runtime domain if loaded, otherwise fall back to build-time value
|
||||
if (isLoading || !config) {
|
||||
return process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
}
|
||||
return config.domain;
|
||||
}
|
||||
+12
-2
@@ -65,6 +65,7 @@ export async function uploadToUserStorage(
|
||||
mimeType: string,
|
||||
provider: StorageProvider,
|
||||
endpoint: string | null,
|
||||
publicBaseUrl: string | null,
|
||||
region: string,
|
||||
bucket: string,
|
||||
encryptedAccessKey: string,
|
||||
@@ -99,8 +100,12 @@ export async function uploadToUserStorage(
|
||||
|
||||
// Construct URL based on provider
|
||||
let url: string;
|
||||
if (endpoint) {
|
||||
// Custom endpoint (R2, MinIO, etc)
|
||||
|
||||
// Priority: user's publicBaseUrl > construct from endpoint > AWS format
|
||||
if (publicBaseUrl) {
|
||||
url = `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
||||
} else if (endpoint) {
|
||||
// Custom endpoint (MinIO, etc)
|
||||
url = `${endpoint}/${bucket}/${key}`;
|
||||
} else {
|
||||
// AWS S3 standard URL
|
||||
@@ -165,6 +170,7 @@ export async function testS3Credentials(
|
||||
export async function generateAndUploadAvatarToUserStorage(
|
||||
handle: string,
|
||||
endpoint: string | undefined,
|
||||
publicBaseUrl: string | undefined,
|
||||
region: string,
|
||||
bucket: string,
|
||||
accessKey: string,
|
||||
@@ -202,6 +208,10 @@ export async function generateAndUploadAvatarToUserStorage(
|
||||
}));
|
||||
|
||||
// 3. Return URL
|
||||
// Priority: user's publicBaseUrl > construct from endpoint > AWS format
|
||||
if (publicBaseUrl) {
|
||||
return `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
if (endpoint) {
|
||||
return `${endpoint}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useDomain } from '@/lib/contexts/ConfigContext';
|
||||
|
||||
// Build-time domain fallback (for SSR/non-React contexts)
|
||||
export const NODE_DOMAIN = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
/**
|
||||
@@ -23,3 +26,28 @@ export function formatFullHandle(handle: string, nodeDomain?: string | null): st
|
||||
const domain = nodeDomain || NODE_DOMAIN;
|
||||
return `@${cleanHandle}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that formats a handle using the runtime domain config.
|
||||
* Use this in client components instead of formatFullHandle for local handles.
|
||||
*
|
||||
* @param handle - The user handle (with or without domain)
|
||||
* @param nodeDomain - Optional domain override for swarm posts
|
||||
*/
|
||||
export function useFormattedHandle(handle: string, nodeDomain?: string | null): string {
|
||||
const runtimeDomain = useDomain();
|
||||
|
||||
if (!handle) return '';
|
||||
|
||||
// Remove leading @ if present for processing
|
||||
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
|
||||
|
||||
// Check if it already has a domain (contains @)
|
||||
if (cleanHandle.includes('@')) {
|
||||
return `@${cleanHandle}`;
|
||||
}
|
||||
|
||||
// Use provided domain (for remote posts) or fall back to runtime domain
|
||||
const domain = nodeDomain || runtimeDomain;
|
||||
return `@${cleanHandle}@${domain}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user