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 configuration (auto or specific port)
|
||||||
PORT: ${PORT:-3000}
|
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 environment
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const registerSchema = z.object({
|
|||||||
// S3-compatible storage credentials
|
// S3-compatible storage credentials
|
||||||
storageProvider: z.string().min(1),
|
storageProvider: z.string().min(1),
|
||||||
storageEndpoint: z.string().nullable().optional(),
|
storageEndpoint: z.string().nullable().optional(),
|
||||||
|
storagePublicBaseUrl: z.string().nullable().optional(),
|
||||||
storageRegion: z.string().min(1),
|
storageRegion: z.string().min(1),
|
||||||
storageBucket: z.string().min(1),
|
storageBucket: z.string().min(1),
|
||||||
storageAccessKey: z.string().min(10),
|
storageAccessKey: z.string().min(10),
|
||||||
@@ -71,6 +72,7 @@ export async function POST(request: Request) {
|
|||||||
data.displayName,
|
data.displayName,
|
||||||
data.storageProvider,
|
data.storageProvider,
|
||||||
data.storageEndpoint || null,
|
data.storageEndpoint || null,
|
||||||
|
data.storagePublicBaseUrl || null,
|
||||||
data.storageRegion,
|
data.storageRegion,
|
||||||
data.storageBucket,
|
data.storageBucket,
|
||||||
data.storageAccessKey,
|
data.storageAccessKey,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export async function POST(request: Request) {
|
|||||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||||
botHandle,
|
botHandle,
|
||||||
user.storageEndpoint || undefined,
|
user.storageEndpoint || undefined,
|
||||||
|
user.storagePublicBaseUrl || undefined,
|
||||||
user.storageRegion || 'auto',
|
user.storageRegion || 'auto',
|
||||||
user.storageBucket,
|
user.storageBucket,
|
||||||
accessKeyId,
|
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,
|
file.type,
|
||||||
user.storageProvider as any,
|
user.storageProvider as any,
|
||||||
user.storageEndpoint,
|
user.storageEndpoint,
|
||||||
|
user.storagePublicBaseUrl,
|
||||||
user.storageRegion || 'us-east-1',
|
user.storageRegion || 'us-east-1',
|
||||||
user.storageBucket || '',
|
user.storageBucket || '',
|
||||||
user.storageAccessKeyEncrypted,
|
user.storageAccessKeyEncrypted,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
|
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';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
@@ -43,6 +43,7 @@ export default function ChatPage() {
|
|||||||
// Chat Data State
|
// Chat Data State
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||||
|
const selectedHandle = selectedConversation ? useFormattedHandle(selectedConversation.participant2.handle) : '';
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -389,7 +390,7 @@ export default function ChatPage() {
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
|
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{formatFullHandle(selectedConversation.participant2.handle)}
|
{selectedHandle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
|
import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
|
||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { Post } from '@/lib/types';
|
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 { Bot, Network, Server, EyeOff } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
|
const fullHandle = useFormattedHandle(user.handle);
|
||||||
return (
|
return (
|
||||||
<Link href={`/u/${user.handle}`} className="user-card">
|
<Link href={`/u/${user.handle}`} className="user-card">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
@@ -52,7 +53,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>}
|
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+12
-9
@@ -57,6 +57,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
||||||
import { ToastProvider } from '@/lib/contexts/ToastContext';
|
import { ToastProvider } from '@/lib/contexts/ToastContext';
|
||||||
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
|
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
|
||||||
|
import { ConfigProvider } from '@/lib/contexts/ConfigContext';
|
||||||
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -67,15 +68,17 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>
|
<ConfigProvider>
|
||||||
<AccentColorProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>
|
<AccentColorProvider>
|
||||||
<LayoutWrapper>
|
<ToastProvider>
|
||||||
{children}
|
<LayoutWrapper>
|
||||||
</LayoutWrapper>
|
{children}
|
||||||
</ToastProvider>
|
</LayoutWrapper>
|
||||||
</AccentColorProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AccentColorProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ConfigProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+45
-15
@@ -34,6 +34,7 @@ export default function LoginPage() {
|
|||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [storageProvider, setStorageProvider] = useState('r2');
|
const [storageProvider, setStorageProvider] = useState('r2');
|
||||||
const [storageEndpoint, setStorageEndpoint] = useState('');
|
const [storageEndpoint, setStorageEndpoint] = useState('');
|
||||||
|
const [storagePublicBaseUrl, setStoragePublicBaseUrl] = useState('');
|
||||||
const [storageRegion, setStorageRegion] = useState('auto');
|
const [storageRegion, setStorageRegion] = useState('auto');
|
||||||
const [storageBucket, setStorageBucket] = useState('');
|
const [storageBucket, setStorageBucket] = useState('');
|
||||||
const [storageAccessKey, setStorageAccessKey] = useState('');
|
const [storageAccessKey, setStorageAccessKey] = useState('');
|
||||||
@@ -252,6 +253,7 @@ export default function LoginPage() {
|
|||||||
displayName,
|
displayName,
|
||||||
storageProvider,
|
storageProvider,
|
||||||
storageEndpoint: storageEndpoint || null,
|
storageEndpoint: storageEndpoint || null,
|
||||||
|
storagePublicBaseUrl: storagePublicBaseUrl || null,
|
||||||
storageRegion,
|
storageRegion,
|
||||||
storageBucket,
|
storageBucket,
|
||||||
storageAccessKey,
|
storageAccessKey,
|
||||||
@@ -573,6 +575,7 @@ export default function LoginPage() {
|
|||||||
<option value="r2">Cloudflare R2 (10GB free)</option>
|
<option value="r2">Cloudflare R2 (10GB free)</option>
|
||||||
<option value="b2">Backblaze B2 (10GB free)</option>
|
<option value="b2">Backblaze B2 (10GB free)</option>
|
||||||
<option value="wasabi">Wasabi</option>
|
<option value="wasabi">Wasabi</option>
|
||||||
|
<option value="contabo">Contabo S3</option>
|
||||||
<option value="s3">AWS S3</option>
|
<option value="s3">AWS S3</option>
|
||||||
<option value="minio">MinIO / Self-hosted</option>
|
<option value="minio">MinIO / Self-hosted</option>
|
||||||
<option value="other">Other S3-compatible</option>
|
<option value="other">Other S3-compatible</option>
|
||||||
@@ -611,22 +614,49 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* S3 Credentials - Full Width */}
|
{/* Endpoint URL - only show for providers that need it (R2, B2, Contabo, MinIO, Other) */}
|
||||||
<div style={{ marginBottom: '16px' }}>
|
{(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo' || storageProvider === 'minio' || storageProvider === 'other') && (
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
Endpoint URL (optional for AWS)
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
</label>
|
Endpoint URL
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
className="input"
|
type="text"
|
||||||
value={storageEndpoint}
|
className="input"
|
||||||
onChange={(e) => setStorageEndpoint(e.target.value)}
|
value={storageEndpoint}
|
||||||
placeholder="https://<account>.r2.cloudflarestorage.com"
|
onChange={(e) => setStorageEndpoint(e.target.value)}
|
||||||
/>
|
placeholder={storageProvider === 'contabo' ? 'https://s3.eu2.contabo.com' : 'https://<account>.r2.cloudflarestorage.com'}
|
||||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
required
|
||||||
Leave empty for AWS S3. Required for R2, B2, MinIO.
|
/>
|
||||||
|
<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>
|
||||||
</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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
@@ -65,6 +65,7 @@ const FlagIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
|
const fullHandle = useFormattedHandle(user.handle);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/@${user.handle}`}
|
href={`/@${user.handle}`}
|
||||||
@@ -107,7 +108,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{user.bio && (
|
||||||
<div style={{
|
<div style={{
|
||||||
color: 'var(--foreground-secondary)',
|
color: 'var(--foreground-secondary)',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { PostCard } from '@/components/PostCard';
|
|||||||
import { User, Post } from '@/lib/types';
|
import { User, Post } from '@/lib/types';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
|
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 { Bot } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ const stripHtml = (html: string | null | undefined): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function UserRow({ user }: { user: UserSummary }) {
|
function UserRow({ user }: { user: UserSummary }) {
|
||||||
|
const fullHandle = useFormattedHandle(user.handle);
|
||||||
return (
|
return (
|
||||||
<Link href={`/u/${user.handle}`} className="user-row">
|
<Link href={`/u/${user.handle}`} className="user-row">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
@@ -66,7 +67,7 @@ function UserRow({ user }: { user: UserSummary }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) && (
|
{user.bio && stripHtml(user.bio) && (
|
||||||
<div className="user-row-bio">{stripHtml(user.bio)}</div>
|
<div className="user-row-bio">{stripHtml(user.bio)}</div>
|
||||||
)}
|
)}
|
||||||
@@ -82,6 +83,7 @@ export default function ProfilePage() {
|
|||||||
const { isIdentityUnlocked, signUserAction } = useAuth();
|
const { isIdentityUnlocked, signUserAction } = useAuth();
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
|
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
|
||||||
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
||||||
@@ -719,7 +721,7 @@ export default function ProfilePage() {
|
|||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div style={{ padding: '12px 0' }}>
|
<div style={{ padding: '12px 0' }}>
|
||||||
<h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2>
|
<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 && (
|
{user.bio && (
|
||||||
<p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p>
|
<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 { Post, Attachment } from '@/lib/types';
|
||||||
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
|
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
|
||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
interface MediaAttachment extends Attachment {
|
interface MediaAttachment extends Attachment {
|
||||||
@@ -22,6 +22,7 @@ interface ComposeProps {
|
|||||||
|
|
||||||
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
|
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
|
||||||
const { isIdentityUnlocked } = useAuth();
|
const { isIdentityUnlocked } = useAuth();
|
||||||
|
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
||||||
@@ -161,7 +162,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
{replyingTo && !isReply && (
|
{replyingTo && !isReply && (
|
||||||
<div className="compose-reply-target">
|
<div className="compose-reply-target">
|
||||||
<div className="compose-reply-info">
|
<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>
|
</div>
|
||||||
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
|
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { useAuth } from '@/lib/contexts/AuthContext';
|
|||||||
import { useToast } from '@/lib/contexts/ToastContext';
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import BlurredVideo from '@/components/BlurredVideo';
|
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';
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
|
|
||||||
// Component for link preview image that hides on error
|
// 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 [reporting, setReporting] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [showMenu, setShowMenu] = 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)
|
// Sync state if post changes (e.g. after a re-render from parent)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -263,7 +267,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
return post.author.handle;
|
return post.author.handle;
|
||||||
}
|
}
|
||||||
// If this is a swarm post from a DIFFERENT node, append the node domain
|
// 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}`;
|
return `${post.author.handle}@${post.nodeDomain}`;
|
||||||
}
|
}
|
||||||
// Local user
|
// 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()}>
|
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||||
{post.author.displayName || post.author.handle}
|
{post.author.displayName || post.author.handle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span>
|
<span className="post-time">{authorHandle}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{currentUser && currentUser.id !== post.author.id && (
|
{currentUser && currentUser.id !== post.author.id && (
|
||||||
<div style={{ position: 'relative', marginLeft: 'auto' }}>
|
<div style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||||
@@ -591,7 +595,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
|
|
||||||
{effectiveReplyTo && !showThread && (
|
{effectiveReplyTo && !showThread && (
|
||||||
<div className="post-reply-to">
|
<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>
|
</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
|
// Allow deleting own remote posts where handle might be username@node_domain
|
||||||
(post.author.id.startsWith('swarm:') && (
|
(post.author.id.startsWith('swarm:') && (
|
||||||
post.author.handle === currentUser.handle ||
|
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">
|
<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 { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
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 { LogOut, Settings2 } from 'lucide-react';
|
||||||
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
|
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export function Sidebar() {
|
|||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const formattedHandle = user ? useFormattedHandle(user.handle) : '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node')
|
fetch('/api/node')
|
||||||
@@ -188,7 +189,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
|
<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>
|
||||||
</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
|
movedFrom: text('moved_from'), // Old actor URL if this account migrated here
|
||||||
migratedAt: timestamp('migrated_at'), // When the migration occurred
|
migratedAt: timestamp('migrated_at'), // When the migration occurred
|
||||||
// User-owned S3-compatible storage - required for new users
|
// 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)
|
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')
|
storageRegion: text('storage_region'), // Region (e.g., 'us-east-1')
|
||||||
storageBucket: text('storage_bucket'), // Bucket name
|
storageBucket: text('storage_bucket'), // Bucket name
|
||||||
storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key
|
storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export async function registerUser(
|
|||||||
displayName?: string,
|
displayName?: string,
|
||||||
storageProvider?: string,
|
storageProvider?: string,
|
||||||
storageEndpoint?: string | null,
|
storageEndpoint?: string | null,
|
||||||
|
storagePublicBaseUrl?: string | null,
|
||||||
storageRegion?: string,
|
storageRegion?: string,
|
||||||
storageBucket?: string,
|
storageBucket?: string,
|
||||||
storageAccessKey?: string,
|
storageAccessKey?: string,
|
||||||
@@ -201,6 +202,7 @@ export async function registerUser(
|
|||||||
const avatarUrl = await generateAndUploadAvatarToUserStorage(
|
const avatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||||
fullHandle,
|
fullHandle,
|
||||||
storageEndpoint || undefined,
|
storageEndpoint || undefined,
|
||||||
|
storagePublicBaseUrl || undefined,
|
||||||
storageRegion,
|
storageRegion,
|
||||||
storageBucket,
|
storageBucket,
|
||||||
storageAccessKey,
|
storageAccessKey,
|
||||||
@@ -222,6 +224,7 @@ export async function registerUser(
|
|||||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||||
storageProvider,
|
storageProvider,
|
||||||
storageEndpoint: storageEndpoint || null,
|
storageEndpoint: storageEndpoint || null,
|
||||||
|
storagePublicBaseUrl: storagePublicBaseUrl || null,
|
||||||
storageRegion,
|
storageRegion,
|
||||||
storageBucket,
|
storageBucket,
|
||||||
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
|
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,
|
mimeType: string,
|
||||||
provider: StorageProvider,
|
provider: StorageProvider,
|
||||||
endpoint: string | null,
|
endpoint: string | null,
|
||||||
|
publicBaseUrl: string | null,
|
||||||
region: string,
|
region: string,
|
||||||
bucket: string,
|
bucket: string,
|
||||||
encryptedAccessKey: string,
|
encryptedAccessKey: string,
|
||||||
@@ -99,8 +100,12 @@ export async function uploadToUserStorage(
|
|||||||
|
|
||||||
// Construct URL based on provider
|
// Construct URL based on provider
|
||||||
let url: string;
|
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}`;
|
url = `${endpoint}/${bucket}/${key}`;
|
||||||
} else {
|
} else {
|
||||||
// AWS S3 standard URL
|
// AWS S3 standard URL
|
||||||
@@ -165,6 +170,7 @@ export async function testS3Credentials(
|
|||||||
export async function generateAndUploadAvatarToUserStorage(
|
export async function generateAndUploadAvatarToUserStorage(
|
||||||
handle: string,
|
handle: string,
|
||||||
endpoint: string | undefined,
|
endpoint: string | undefined,
|
||||||
|
publicBaseUrl: string | undefined,
|
||||||
region: string,
|
region: string,
|
||||||
bucket: string,
|
bucket: string,
|
||||||
accessKey: string,
|
accessKey: string,
|
||||||
@@ -202,6 +208,10 @@ export async function generateAndUploadAvatarToUserStorage(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. Return URL
|
// 3. Return URL
|
||||||
|
// Priority: user's publicBaseUrl > construct from endpoint > AWS format
|
||||||
|
if (publicBaseUrl) {
|
||||||
|
return `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
||||||
|
}
|
||||||
if (endpoint) {
|
if (endpoint) {
|
||||||
return `${endpoint}/${bucket}/${key}`;
|
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';
|
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;
|
const domain = nodeDomain || NODE_DOMAIN;
|
||||||
return `@${cleanHandle}@${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