Initial commit

This commit is contained in:
root
2026-01-26 08:34:48 +01:00
commit 387314581f
216 changed files with 81204 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+732
View File
@@ -0,0 +1,732 @@
/**
* Bot Detail/Edit Page
*
* View and edit bot configuration, manage sources, view logs.
*
* Requirements: 1.3, 4.6, 8.2
*/
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Play, Pause, Rss, Activity, Settings, Sparkles, Clock, Trash2, Pencil } from 'lucide-react';
import { useToast } from '@/lib/contexts/ToastContext';
export default function BotDetailPage() {
const params = useParams();
const router = useRouter();
const { showToast } = useToast();
const botId = params.id as string;
const [bot, setBot] = useState<any>(null);
const [sources, setSources] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showAddSource, setShowAddSource] = useState(false);
const [newSource, setNewSource] = useState({
type: 'rss' as 'rss' | 'reddit' | 'news_api' | 'brave_news',
url: '',
subreddit: '',
apiKey: '',
// Brave News config
braveQuery: '',
braveFreshness: 'pw' as 'pd' | 'pw' | 'pm' | 'py',
braveCountry: '',
// News API config
newsProvider: 'newsapi' as 'newsapi' | 'gnews' | 'newsdata',
newsQuery: '',
newsCategory: '',
newsCountry: '',
newsLanguage: '',
});
useEffect(() => {
fetchBot();
fetchSources();
}, [botId]);
const fetchBot = async () => {
try {
const response = await fetch(`/api/bots/${botId}`);
if (response.ok) {
const data = await response.json();
setBot(data.bot);
}
} catch (error) {
console.error('Failed to fetch bot:', error);
} finally {
setLoading(false);
}
};
const fetchSources = async () => {
try {
const response = await fetch(`/api/bots/${botId}/sources`);
if (response.ok) {
const data = await response.json();
setSources(data.sources || []);
}
} catch (error) {
console.error('Failed to fetch sources:', error);
}
};
const handleTriggerPost = async () => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
showToast('Post triggered successfully!', 'success');
fetchBot();
} else {
const data = await response.json();
showToast(`Failed to trigger post: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showToast('Failed to trigger post', 'error');
} finally {
setActionLoading(false);
}
};
const handleToggleActive = async () => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !bot.isActive }),
});
if (response.ok) {
fetchBot();
}
} catch (error) {
console.error('Failed to toggle bot:', error);
} finally {
setActionLoading(false);
}
};
const handleAddSource = async () => {
setActionLoading(true);
try {
let url = newSource.url;
const payload: Record<string, unknown> = {
type: newSource.type,
apiKey: newSource.apiKey || undefined,
};
// Build URL and config based on type
if (newSource.type === 'brave_news') {
if (!newSource.braveQuery || !newSource.apiKey) {
showToast('Search query and API key are required for Brave News', 'error');
setActionLoading(false);
return;
}
const braveUrl = new URL('https://api.search.brave.com/res/v1/news/search');
braveUrl.searchParams.set('q', newSource.braveQuery);
if (newSource.braveFreshness) braveUrl.searchParams.set('freshness', newSource.braveFreshness);
if (newSource.braveCountry) braveUrl.searchParams.set('country', newSource.braveCountry);
url = braveUrl.toString();
payload.braveNewsConfig = {
query: newSource.braveQuery,
freshness: newSource.braveFreshness,
country: newSource.braveCountry || undefined,
};
} else if (newSource.type === 'news_api') {
if (!newSource.newsQuery || !newSource.apiKey) {
showToast('Search query and API key are required for News API', 'error');
setActionLoading(false);
return;
}
let baseUrl: string;
const params = new URLSearchParams();
switch (newSource.newsProvider) {
case 'gnews':
baseUrl = 'https://gnews.io/api/v4/search';
params.set('q', newSource.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
break;
case 'newsdata':
baseUrl = 'https://newsdata.io/api/1/news';
params.set('q', newSource.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
break;
default:
baseUrl = 'https://newsapi.org/v2/everything';
params.set('q', newSource.newsQuery);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
}
url = `${baseUrl}?${params.toString()}`;
payload.newsApiConfig = {
provider: newSource.newsProvider,
query: newSource.newsQuery,
category: newSource.newsCategory || undefined,
country: newSource.newsCountry || undefined,
language: newSource.newsLanguage || undefined,
};
} else if (newSource.type === 'reddit') {
payload.subreddit = newSource.subreddit || undefined;
}
payload.url = url;
const response = await fetch(`/api/bots/${botId}/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
setShowAddSource(false);
setNewSource({
type: 'rss',
url: '',
subreddit: '',
apiKey: '',
braveQuery: '',
braveFreshness: 'pw',
braveCountry: '',
newsProvider: 'newsapi',
newsQuery: '',
newsCategory: '',
newsCountry: '',
newsLanguage: '',
});
fetchSources();
} else {
const data = await response.json();
showToast(`Failed to add source: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showToast('Failed to add source', 'error');
} finally {
setActionLoading(false);
}
};
const handleFetchSource = async (sourceId: string) => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}/sources/${sourceId}/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
const data = await response.json();
showToast(`Fetched ${data.itemsFetched || 0} items successfully!`, 'success');
fetchSources();
} else {
const data = await response.json();
showToast(`Failed to fetch content: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showToast('Failed to fetch content', 'error');
} finally {
setActionLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
if (!bot) {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<p style={{ color: 'var(--foreground-tertiary)' }}>Bot not found</p>
<Link href="/settings/bots" className="btn" style={{ marginTop: '16px' }}>
Back to Bots
</Link>
</div>
</div>
);
}
const scheduleConfig = typeof bot.scheduleConfig === 'string'
? JSON.parse(bot.scheduleConfig)
: bot.scheduleConfig || null;
const personalityConfig = typeof bot.personalityConfig === 'string'
? JSON.parse(bot.personalityConfig)
: bot.personalityConfig || {};
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>{bot.name}</h1>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '4px 10px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Autonomous
</span>
)}
</div>
<p style={{ fontSize: '14px', color: 'var(--foreground-tertiary)' }}>
@{bot.handle}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{bot.isSuspended ? (
<span className="status-pill suspended">Suspended</span>
) : bot.isActive ? (
<span className="status-pill active">Active</span>
) : (
<span className="status-pill">Inactive</span>
)}
</div>
</header>
{/* Quick Actions */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Play size={18} />
Quick Actions
</h2>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={handleTriggerPost}
disabled={actionLoading || bot.isSuspended}
className="btn btn-primary"
>
<Play size={16} />
Trigger Post
</button>
<button
onClick={handleToggleActive}
disabled={actionLoading || bot.isSuspended}
className="btn"
>
{bot.isActive ? <Pause size={16} /> : <Play size={16} />}
{bot.isActive ? 'Deactivate' : 'Activate'}
</button>
<Link
href={`/settings/bots/${botId}/edit`}
className="btn"
>
<Pencil size={16} />
Edit Bot
</Link>
</div>
</div>
{/* Bot Info */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bot Information
</h2>
<div style={{ display: 'grid', gap: '12px' }}>
{bot.bio && (
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
Bio
</div>
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
{bot.bio}
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
Last Post
</div>
<div style={{ fontSize: '14px' }}>
{bot.lastPostAt ? new Date(bot.lastPostAt).toLocaleString() : 'Never'}
</div>
</div>
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
Created
</div>
<div style={{ fontSize: '14px' }}>
{new Date(bot.createdAt).toLocaleDateString()}
</div>
</div>
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
LLM Provider
</div>
<div style={{ fontSize: '14px' }}>
{bot.llmProvider} / {bot.llmModel}
</div>
</div>
</div>
</div>
</div>
{/* Schedule */}
{bot.autonomousMode && scheduleConfig && (
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Clock size={18} />
Posting Schedule
</h2>
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
{scheduleConfig.type === 'interval' && scheduleConfig.intervalMinutes
? `Posts every ${scheduleConfig.intervalMinutes} minutes`
: 'Custom schedule configured'}
</div>
</div>
)}
{/* Personality */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Sparkles size={18} />
Personality
</h2>
<div style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
background: 'var(--background-tertiary)',
padding: '12px',
borderRadius: 'var(--radius-md)',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
}}>
{personalityConfig?.systemPrompt || 'No system prompt configured'}
</div>
</div>
{/* Content Sources */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rss size={18} />
Content Sources ({sources.length})
</h2>
<button
onClick={() => setShowAddSource(!showAddSource)}
className="btn btn-sm btn-primary"
>
{showAddSource ? 'Cancel' : '+ Add Source'}
</button>
</div>
{showAddSource && (
<div className="card" style={{ padding: '16px', marginBottom: '16px', background: 'var(--background-tertiary)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Source Type
</label>
<select
value={newSource.type}
onChange={(e) => setNewSource({ ...newSource, type: e.target.value as typeof newSource.type })}
className="input"
>
<option value="rss">RSS Feed</option>
<option value="reddit">Reddit</option>
<option value="brave_news">Brave News Search</option>
<option value="news_api">News API (Advanced)</option>
</select>
</div>
{newSource.type === 'rss' && (
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
RSS Feed URL
</label>
<input
type="url"
value={newSource.url}
onChange={(e) => setNewSource({ ...newSource, url: e.target.value })}
className="input"
placeholder="https://example.com/feed.xml"
/>
</div>
)}
{newSource.type === 'reddit' && (
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Subreddit
</label>
<input
type="text"
value={newSource.subreddit}
onChange={(e) => setNewSource({ ...newSource, subreddit: e.target.value, url: `https://reddit.com/r/${e.target.value}` })}
className="input"
placeholder="technology"
/>
</div>
)}
{newSource.type === 'brave_news' && (
<>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Search Query
</label>
<input
type="text"
value={newSource.braveQuery}
onChange={(e) => setNewSource({ ...newSource, braveQuery: e.target.value })}
className="input"
placeholder="AI technology, climate change, etc."
/>
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Use quotes for exact phrases, minus to exclude terms
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Freshness
</label>
<select
value={newSource.braveFreshness}
onChange={(e) => setNewSource({ ...newSource, braveFreshness: e.target.value as typeof newSource.braveFreshness })}
className="input"
>
<option value="pd">Last 24 hours</option>
<option value="pw">Last 7 days</option>
<option value="pm">Last 31 days</option>
<option value="py">Last year</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Country (optional)
</label>
<select
value={newSource.braveCountry}
onChange={(e) => setNewSource({ ...newSource, braveCountry: e.target.value })}
className="input"
>
<option value="">All countries</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="JP">Japan</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Brave API Key
</label>
<input
type="password"
value={newSource.apiKey}
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
className="input"
placeholder="Your Brave Search API key"
/>
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Get your API key at <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>brave.com/search/api</a>
</p>
</div>
</>
)}
{newSource.type === 'news_api' && (
<>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
News Provider
</label>
<select
value={newSource.newsProvider}
onChange={(e) => setNewSource({ ...newSource, newsProvider: e.target.value as typeof newSource.newsProvider })}
className="input"
>
<option value="newsapi">NewsAPI.org</option>
<option value="gnews">GNews.io</option>
<option value="newsdata">NewsData.io</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Search Keywords
</label>
<input
type="text"
value={newSource.newsQuery}
onChange={(e) => setNewSource({ ...newSource, newsQuery: e.target.value })}
className="input"
placeholder="technology, AI, startups"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Category (optional)
</label>
<select
value={newSource.newsCategory}
onChange={(e) => setNewSource({ ...newSource, newsCategory: e.target.value })}
className="input"
>
<option value="">All categories</option>
<option value="technology">Technology</option>
<option value="business">Business</option>
<option value="science">Science</option>
<option value="health">Health</option>
<option value="sports">Sports</option>
<option value="entertainment">Entertainment</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Country (optional)
</label>
<select
value={newSource.newsCountry}
onChange={(e) => setNewSource({ ...newSource, newsCountry: e.target.value })}
className="input"
>
<option value="">All countries</option>
<option value="us">United States</option>
<option value="gb">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
<option value="de">Germany</option>
<option value="fr">France</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
API Key
</label>
<input
type="password"
value={newSource.apiKey}
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
className="input"
placeholder="Your API key"
/>
</div>
</>
)}
<button
onClick={handleAddSource}
disabled={
actionLoading ||
(newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
}
className="btn btn-primary"
>
{actionLoading ? 'Adding...' : 'Add Source'}
</button>
</div>
</div>
)}
{sources.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', textAlign: 'center', padding: '24px 0' }}>
No content sources configured. Add a source to enable posting.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{sources.map((source: any) => {
let displayName = '';
try {
if (source.type === 'brave_news') {
const urlObj = new URL(source.url);
displayName = urlObj.searchParams.get('q') || 'Brave News';
} else if (source.subreddit) {
displayName = source.subreddit;
} else if (source.url) {
displayName = new URL(source.url).hostname;
} else {
displayName = 'Unknown';
}
} catch {
displayName = source.url || 'Unknown';
}
return (
<div
key={source.id}
style={{
padding: '12px',
background: 'var(--background-tertiary)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px' }}>
{source.type === 'brave_news' ? 'BRAVE NEWS' : source.type.toUpperCase()} - {displayName}
</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
{source.lastFetchAt ? `Last fetched: ${new Date(source.lastFetchAt).toLocaleString()}` : 'Never fetched'}
{source.lastError && <span style={{ color: 'var(--error)' }}> Error: {source.lastError}</span>}
</div>
</div>
<span className={`status-pill ${source.isActive ? 'active' : ''}`}>
{source.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Danger Zone */}
<div className="card" style={{ padding: '20px', borderColor: 'var(--error)' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', color: 'var(--error)', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Trash2 size={18} />
Danger Zone
</h2>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginBottom: '12px' }}>
Deleting a bot is permanent and cannot be undone. All associated data will be removed.
</p>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
.then(() => router.push('/settings/bots'))
.catch(() => showToast('Failed to delete bot', 'error'));
}
}}
className="btn"
style={{ color: 'var(--error)', borderColor: 'var(--error)' }}
>
<Trash2 size={16} />
Delete Bot
</button>
</div>
</div>
);
}
+960
View File
@@ -0,0 +1,960 @@
/**
* Bot Creation Page
*
* Form for creating a new bot.
*
* Requirements: 1.1, 2.1, 3.1
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
interface ContentSource {
type: 'rss' | 'reddit' | 'news_api' | 'brave_news' | 'youtube';
url: string;
subreddit?: string;
apiKey?: string;
// Brave News config
braveQuery?: string;
braveFreshness?: 'pd' | 'pw' | 'pm' | 'py';
braveCountry?: string;
// News API config
newsProvider?: 'newsapi' | 'gnews' | 'newsdata';
newsQuery?: string;
newsCategory?: string;
newsCountry?: string;
newsLanguage?: string;
// YouTube config
youtubeChannelId?: string;
youtubePlaylistId?: string;
}
export default function NewBotPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingBanner, setUploadingBanner] = useState(false);
const [formData, setFormData] = useState({
name: '',
handle: '',
bio: '',
avatarUrl: '',
headerUrl: '',
systemPrompt: '',
llmProvider: 'openai',
llmModel: 'gpt-4',
llmApiKey: '',
autonomousMode: false,
postingFrequency: 'hourly',
customIntervalMinutes: 60,
});
const [sources, setSources] = useState<ContentSource[]>([]);
const [newSource, setNewSource] = useState<ContentSource>({
type: 'rss',
url: '',
braveQuery: '',
braveFreshness: 'pw',
newsProvider: 'newsapi',
newsQuery: '',
});
// Fetch previous bot settings to pre-fill LLM config
useEffect(() => {
const fetchPreviousBotSettings = async () => {
try {
const res = await fetch('/api/bots');
if (res.ok) {
const data = await res.json();
if (data.bots && data.bots.length > 0) {
// Get the most recently created bot
const sortedBots = [...data.bots].sort(
(a: { createdAt: string }, b: { createdAt: string }) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const lastBot = sortedBots[0];
// Pre-fill LLM settings (but not API key for security)
setFormData(prev => ({
...prev,
llmProvider: lastBot.llmProvider || 'openai',
llmModel: lastBot.llmModel || 'gpt-4',
}));
}
}
} catch (err) {
// Silently fail - not critical
console.error('Failed to fetch previous bot settings:', err);
}
};
fetchPreviousBotSettings();
}, []);
const handleAddSource = () => {
// Validate based on type
if (newSource.type === 'brave_news') {
if (!newSource.braveQuery || !newSource.apiKey) return;
// Build URL from config
const url = new URL('https://api.search.brave.com/res/v1/news/search');
url.searchParams.set('q', newSource.braveQuery);
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
setSources([...sources, { ...newSource, url: url.toString() }]);
} else if (newSource.type === 'news_api') {
if (!newSource.newsQuery || !newSource.apiKey) return;
// Build URL from config
let baseUrl: string;
const params = new URLSearchParams();
switch (newSource.newsProvider) {
case 'gnews':
baseUrl = 'https://gnews.io/api/v4/search';
params.set('q', newSource.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
break;
case 'newsdata':
baseUrl = 'https://newsdata.io/api/1/news';
params.set('q', newSource.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
break;
default:
baseUrl = 'https://newsapi.org/v2/everything';
params.set('q', newSource.newsQuery);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
}
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
} else {
if (!newSource.url) return;
setSources([...sources, { ...newSource }]);
}
setNewSource({
type: 'rss',
url: '',
braveQuery: '',
braveFreshness: 'pw',
newsProvider: 'newsapi',
newsQuery: '',
});
};
const handleRemoveSource = (index: number) => {
setSources(sources.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
// Calculate interval minutes based on frequency
let intervalMinutes = 60;
if (formData.postingFrequency === 'hourly') intervalMinutes = 60;
else if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
else if (formData.postingFrequency === 'custom') intervalMinutes = formData.customIntervalMinutes;
const response = await fetch('/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
handle: formData.handle,
bio: formData.bio,
avatarUrl: formData.avatarUrl || undefined,
headerUrl: formData.headerUrl || undefined,
personality: {
systemPrompt: formData.systemPrompt,
temperature: 0.7, // Default value since posts are limited to 400 chars
maxTokens: 500, // Default value, sufficient for 400 char posts
},
llmProvider: formData.llmProvider,
llmModel: formData.llmModel,
llmApiKey: formData.llmApiKey,
autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? {
type: 'interval',
intervalMinutes,
} : undefined,
}),
});
if (response.ok) {
const data = await response.json();
// If sources were added, create them after bot creation
if (sources.length > 0) {
for (const source of sources) {
const sourcePayload: Record<string, unknown> = {
type: source.type,
url: source.url,
subreddit: source.subreddit,
apiKey: source.apiKey,
};
// Add config for brave_news
if (source.type === 'brave_news' && source.braveQuery) {
sourcePayload.braveNewsConfig = {
query: source.braveQuery,
freshness: source.braveFreshness,
country: source.braveCountry,
};
}
// Add config for news_api
if (source.type === 'news_api' && source.newsQuery) {
sourcePayload.newsApiConfig = {
provider: source.newsProvider,
query: source.newsQuery,
category: source.newsCategory,
country: source.newsCountry,
language: source.newsLanguage,
};
}
await fetch(`/api/bots/${data.bot.id}/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourcePayload),
});
}
// Trigger the first post to establish a reference point for the schedule
// This runs in the background - we don't wait for it
fetch(`/api/bots/${data.bot.id}/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}).catch(() => {
// Silently fail - the bot will post on the next scheduled run
});
}
router.push(`/settings/bots/${data.bot.id}`);
} else {
const data = await response.json();
console.error('Bot creation failed:', data);
const errorMsg = data.error || 'Failed to create bot';
const detailsMsg = data.details ? '\n' + JSON.stringify(data.details, null, 2) : '';
setError(errorMsg + detailsMsg);
}
} catch (err) {
console.error('Create bot error:', err);
setError('Failed to create bot');
} finally {
setLoading(false);
}
};
const renderStep = () => {
switch (step) {
case 'identity':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Bot Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
placeholder="My Awesome Bot"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Handle
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--foreground-tertiary)' }}>@</span>
<input
type="text"
value={formData.handle}
onChange={(e) => setFormData({ ...formData, handle: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })}
className="input"
placeholder="mybot"
style={{ flex: 1 }}
/>
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Lowercase letters, numbers, and underscores only
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Bio
</label>
<textarea
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
className="input"
rows={3}
placeholder="A brief description of what your bot does..."
style={{ resize: 'vertical' }}
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
{formData.bio.length}/400 characters
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Avatar
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
try {
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: uploadData,
});
const data = await res.json();
if (data.url) {
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
}
} catch (err) {
console.error('Avatar upload failed:', err);
setError('Avatar upload failed');
} finally {
setUploadingAvatar(false);
}
}}
disabled={uploadingAvatar}
style={{ display: 'none' }}
/>
</label>
{formData.avatarUrl && (
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{formData.avatarUrl && (
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Square image recommended (optional)
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Banner
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
{uploadingBanner ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingBanner(true);
try {
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: uploadData,
});
const data = await res.json();
if (data.url) {
setFormData(prev => ({ ...prev, headerUrl: data.url }));
}
} catch (err) {
console.error('Banner upload failed:', err);
setError('Banner upload failed');
} finally {
setUploadingBanner(false);
}
}}
disabled={uploadingBanner}
style={{ display: 'none' }}
/>
</label>
{formData.headerUrl && (
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{formData.headerUrl && (
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Wide image recommended, e.g. 1500x500 (optional)
</p>
</div>
</div>
);
case 'personality':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
System Prompt
</label>
<textarea
value={formData.systemPrompt}
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
className="input"
rows={6}
placeholder="You are a helpful bot that shares interesting tech news and engages with users about technology..."
style={{ resize: 'vertical' }}
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Define your bot's personality, tone, and behavior
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
LLM Provider
</label>
<select
value={formData.llmProvider}
onChange={(e) => setFormData({ ...formData, llmProvider: e.target.value })}
className="input"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openrouter">OpenRouter</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Model
</label>
<input
type="text"
value={formData.llmModel}
onChange={(e) => setFormData({ ...formData, llmModel: e.target.value })}
className="input"
placeholder="gpt-4"
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
e.g., gpt-4, claude-3-opus, etc.
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
API Key
</label>
<input
type="password"
value={formData.llmApiKey}
onChange={(e) => setFormData({ ...formData, llmApiKey: e.target.value })}
className="input"
placeholder="sk-..."
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Your API key is encrypted and stored securely
</p>
</div>
</div>
);
case 'sources':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<p style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
Add content sources for your bot to pull from (optional)
</p>
<div className="card" style={{ padding: '16px', marginBottom: '16px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Source Type
</label>
<select
value={newSource.type}
onChange={(e) => setNewSource({ ...newSource, type: e.target.value as ContentSource['type'] })}
className="input"
>
<option value="rss">RSS Feed</option>
<option value="reddit">Reddit</option>
<option value="youtube">YouTube Channel/Playlist</option>
<option value="brave_news">Brave News Search</option>
<option value="news_api">News API (Advanced)</option>
</select>
</div>
{newSource.type === 'rss' && (
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
RSS Feed URL
</label>
<input
type="url"
value={newSource.url}
onChange={(e) => setNewSource({ ...newSource, url: e.target.value })}
className="input"
placeholder="https://example.com/feed.xml"
/>
</div>
)}
{newSource.type === 'youtube' && (
<>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Channel ID or Playlist ID
</label>
<input
type="text"
value={newSource.youtubeChannelId || newSource.youtubePlaylistId || ''}
onChange={(e) => {
const value = e.target.value.trim();
// Detect if it's a playlist ID (starts with PL) or channel ID (starts with UC)
if (value.startsWith('PL')) {
const rssUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${value}`;
setNewSource({ ...newSource, youtubePlaylistId: value, youtubeChannelId: '', url: rssUrl });
} else {
const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${value}`;
setNewSource({ ...newSource, youtubeChannelId: value, youtubePlaylistId: '', url: rssUrl });
}
}}
className="input"
placeholder="UCxxxx... or PLxxxx..."
/>
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Channel IDs start with UC, Playlist IDs start with PL. Find in the YouTube URL.
</p>
</div>
</>
)}
{newSource.type === 'reddit' && (
<>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Subreddit
</label>
<input
type="text"
value={newSource.subreddit || ''}
onChange={(e) => setNewSource({ ...newSource, subreddit: e.target.value, url: `https://reddit.com/r/${e.target.value}` })}
className="input"
placeholder="technology"
/>
</div>
</>
)}
{newSource.type === 'brave_news' && (
<>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Search Query
</label>
<input
type="text"
value={newSource.braveQuery || ''}
onChange={(e) => setNewSource({ ...newSource, braveQuery: e.target.value })}
className="input"
placeholder="AI technology, climate change, etc."
/>
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Use quotes for exact phrases, minus to exclude terms
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Freshness
</label>
<select
value={newSource.braveFreshness || 'pw'}
onChange={(e) => setNewSource({ ...newSource, braveFreshness: e.target.value as ContentSource['braveFreshness'] })}
className="input"
>
<option value="pd">Last 24 hours</option>
<option value="pw">Last 7 days</option>
<option value="pm">Last 31 days</option>
<option value="py">Last year</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Country (optional)
</label>
<select
value={newSource.braveCountry || ''}
onChange={(e) => setNewSource({ ...newSource, braveCountry: e.target.value })}
className="input"
>
<option value="">All countries</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="JP">Japan</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Brave API Key
</label>
<input
type="password"
value={newSource.apiKey || ''}
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
className="input"
placeholder="Your Brave Search API key"
/>
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Get your API key at <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>brave.com/search/api</a>
</p>
</div>
</>
)}
{newSource.type === 'news_api' && (
<>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
News Provider
</label>
<select
value={newSource.newsProvider || 'newsapi'}
onChange={(e) => setNewSource({ ...newSource, newsProvider: e.target.value as ContentSource['newsProvider'] })}
className="input"
>
<option value="newsapi">NewsAPI.org</option>
<option value="gnews">GNews.io</option>
<option value="newsdata">NewsData.io</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Search Keywords
</label>
<input
type="text"
value={newSource.newsQuery || ''}
onChange={(e) => setNewSource({ ...newSource, newsQuery: e.target.value })}
className="input"
placeholder="technology, AI, startups"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Category (optional)
</label>
<select
value={newSource.newsCategory || ''}
onChange={(e) => setNewSource({ ...newSource, newsCategory: e.target.value })}
className="input"
>
<option value="">All categories</option>
<option value="technology">Technology</option>
<option value="business">Business</option>
<option value="science">Science</option>
<option value="health">Health</option>
<option value="sports">Sports</option>
<option value="entertainment">Entertainment</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
Country (optional)
</label>
<select
value={newSource.newsCountry || ''}
onChange={(e) => setNewSource({ ...newSource, newsCountry: e.target.value })}
className="input"
>
<option value="">All countries</option>
<option value="us">United States</option>
<option value="gb">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
<option value="de">Germany</option>
<option value="fr">France</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
API Key
</label>
<input
type="password"
value={newSource.apiKey || ''}
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
className="input"
placeholder="Your API key"
/>
</div>
</>
)}
<button
type="button"
onClick={handleAddSource}
className="btn btn-primary"
disabled={
(newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
}
>
Add Source
</button>
</div>
</div>
{sources.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, marginBottom: '8px' }}>
Added Sources ({sources.length})
</h3>
{sources.map((source, index) => (
<div key={index} className="card" style={{ padding: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '13px', fontWeight: 500 }}>
{source.type === 'brave_news' ? 'BRAVE NEWS' :
source.type === 'youtube' ? 'YOUTUBE' :
source.type.toUpperCase()} - {
source.type === 'brave_news' ? source.braveQuery :
source.type === 'news_api' ? source.newsQuery :
source.type === 'youtube' ? (source.youtubeChannelId || source.youtubePlaylistId) :
source.subreddit || new URL(source.url).hostname
}
</div>
</div>
<button
type="button"
onClick={() => handleRemoveSource(index)}
className="btn btn-sm"
style={{ color: 'var(--error)' }}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
case 'schedule':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
<input
type="checkbox"
checked={formData.autonomousMode}
onChange={(e) => setFormData({ ...formData, autonomousMode: e.target.checked })}
style={{ width: '18px', height: '18px' }}
/>
<span style={{ fontSize: '14px', fontWeight: 500 }}>
Enable Autonomous Mode
</span>
</label>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginLeft: '26px' }}>
Bot will automatically post based on the schedule below
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Posting Frequency
</label>
<select
value={formData.postingFrequency}
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
className="input"
disabled={!formData.autonomousMode}
>
<option value="hourly">Every Hour</option>
<option value="every_2_hours">Every 2 Hours</option>
<option value="every_4_hours">Every 4 Hours</option>
<option value="every_6_hours">Every 6 Hours</option>
<option value="daily">Once Daily</option>
<option value="custom">Custom Interval</option>
</select>
</div>
{formData.postingFrequency === 'custom' && (
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Custom Interval (minutes)
</label>
<input
type="number"
value={formData.customIntervalMinutes}
onChange={(e) => setFormData({ ...formData, customIntervalMinutes: parseInt(e.target.value) })}
className="input"
min="15"
placeholder="60"
disabled={!formData.autonomousMode}
/>
</div>
)}
<div className="card" style={{ padding: '16px', background: 'var(--background-tertiary)' }}>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
{formData.autonomousMode
? `Your bot will automatically post ${formData.postingFrequency === 'custom' ? `every ${formData.customIntervalMinutes} minutes` : formData.postingFrequency.replace('_', ' ')}.`
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
</p>
</div>
</div>
);
}
};
const steps = ['identity', 'personality', 'sources', 'schedule'] as const;
const currentStepIndex = steps.indexOf(step);
const isLastStep = currentStepIndex === steps.length - 1;
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={24} />
Create New Bot
</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Step {currentStepIndex + 1} of {steps.length}: {step.charAt(0).toUpperCase() + step.slice(1)}
</p>
</div>
</header>
{error && (
<div className="card" style={{ padding: '16px', marginBottom: '24px', borderColor: 'var(--error)', background: 'rgba(239, 68, 68, 0.1)' }}>
<p style={{ color: 'var(--error)', fontSize: '14px', whiteSpace: 'pre-wrap', fontFamily: error.includes('{') ? 'monospace' : 'inherit' }}>
{error}
</p>
</div>
)}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
{steps.map((s, i) => (
<div
key={s}
style={{
flex: 1,
height: '4px',
borderRadius: 'var(--radius-full)',
background: i <= currentStepIndex ? 'var(--accent)' : 'var(--border)',
transition: 'background 0.2s ease',
}}
/>
))}
</div>
</div>
<form onSubmit={handleSubmit} onKeyDown={(e) => {
// Prevent Enter key from submitting the form except on the last step
if (e.key === 'Enter' && !isLastStep) {
e.preventDefault();
}
}}>
<div className="card" style={{ padding: '24px', marginBottom: '24px' }}>
{renderStep()}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'space-between' }}>
<button
type="button"
onClick={() => {
if (currentStepIndex > 0) {
setStep(steps[currentStepIndex - 1]);
} else {
router.back();
}
}}
className="btn"
>
{currentStepIndex === 0 ? 'Cancel' : 'Back'}
</button>
{!isLastStep ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setStep(steps[currentStepIndex + 1]);
}}
className="btn btn-primary"
disabled={
(step === 'identity' && (!formData.name || !formData.handle)) ||
(step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey))
}
>
Next
</button>
) : (
<button
type="submit"
disabled={loading}
className="btn btn-primary"
>
{loading ? 'Creating...' : 'Create Bot'}
</button>
)}
</div>
</form>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Bot Management Page
*
* Lists user's bots and provides creation interface.
*
* Requirements: 1.3
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react';
interface BotData {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
lastPostAt: Date | null;
createdAt: Date;
}
export default function BotsPage() {
const router = useRouter();
const [bots, setBots] = useState<BotData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBots();
}, []);
const fetchBots = async () => {
try {
const response = await fetch('/api/bots');
if (response.ok) {
const data = await response.json();
setBots(data.bots || []);
}
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={24} />
My Bots
</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Create and manage your automated bots
</p>
</div>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Bot
</Link>
</header>
{bots.length === 0 ? (
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
No bots yet
</h2>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
Create your first bot to start automating posts and interactions
</p>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Your First Bot
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{bots.map((bot) => (
<div
key={bot.id}
className="card"
style={{
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => router.push(`/settings/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
<span className="status-pill suspended">
Suspended
</span>
) : bot.isActive ? (
<span className="status-pill active">
Active
</span>
) : (
<span className="status-pill">
Inactive
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
gap: '16px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
paddingTop: '12px',
borderTop: '1px solid var(--border)',
}}>
<span>
Last post: {bot.lastPostAt
? new Date(bot.lastPostAt).toLocaleDateString()
: 'Never'}
</span>
<span>
Created: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+380
View File
@@ -0,0 +1,380 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Eye, EyeOff, AlertTriangle, Check } from 'lucide-react';
interface NsfwSettings {
nsfwEnabled: boolean;
ageVerifiedAt: string | null;
isNsfw: boolean;
}
export default function ContentSettingsPage() {
const [settings, setSettings] = useState<NsfwSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showAgeModal, setShowAgeModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings/nsfw');
if (res.ok) {
const data = await res.json();
setSettings(data);
}
} catch {
setError('Failed to load settings');
} finally {
setLoading(false);
}
};
const handleToggleNsfw = async () => {
if (!settings) return;
// If enabling and not verified, show age modal
if (!settings.nsfwEnabled && !settings.ageVerifiedAt) {
setShowAgeModal(true);
return;
}
// Otherwise just toggle
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings/nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nsfwEnabled: !settings.nsfwEnabled }),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => prev ? { ...prev, nsfwEnabled: data.nsfwEnabled } : null);
setSuccess(data.nsfwEnabled ? 'NSFW content enabled' : 'NSFW content disabled');
setTimeout(() => setSuccess(null), 3000);
} else {
const data = await res.json();
setError(data.error || 'Failed to update');
}
} catch {
setError('Failed to update settings');
} finally {
setSaving(false);
}
};
const handleAgeConfirm = async () => {
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings/nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nsfwEnabled: true, confirmAge: true }),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => prev ? {
...prev,
nsfwEnabled: true,
ageVerifiedAt: data.ageVerifiedAt,
} : null);
setShowAgeModal(false);
setSuccess('NSFW content enabled');
setTimeout(() => setSuccess(null), 3000);
} else {
const data = await res.json();
setError(data.error || 'Failed to verify');
}
} catch {
setError('Failed to verify age');
} finally {
setSaving(false);
}
};
const handleToggleAccountNsfw = async () => {
if (!settings) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings/account-nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isNsfw: !settings.isNsfw }),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => prev ? { ...prev, isNsfw: data.isNsfw } : null);
setSuccess(data.isNsfw ? 'Account marked as NSFW' : 'Account unmarked as NSFW');
setTimeout(() => setSuccess(null), 3000);
} else {
const data = await res.json();
setError(data.error || 'Failed to update');
}
} catch {
setError('Failed to update settings');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Content Settings</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
NSFW and content visibility preferences
</p>
</div>
</header>
{error && (
<div style={{
padding: '12px 16px',
background: 'var(--error-muted)',
color: 'var(--error)',
borderRadius: 'var(--radius-md)',
marginBottom: '16px',
fontSize: '14px',
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: '12px 16px',
background: 'var(--success-muted)',
color: 'var(--success)',
borderRadius: 'var(--radius-md)',
marginBottom: '16px',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<Check size={16} />
{success}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* View NSFW Content */}
<div className="card" style={{ padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div style={{ flex: 1 }}>
<div style={{
fontWeight: 600,
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
{settings?.nsfwEnabled ? <Eye size={18} /> : <EyeOff size={18} />}
Show NSFW Content
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
{settings?.nsfwEnabled
? 'You can see posts marked as sensitive or from NSFW accounts/nodes.'
: 'NSFW content is hidden from your feeds and search results.'}
</div>
{settings?.ageVerifiedAt && (
<div style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
}}>
Age verified on {new Date(settings.ageVerifiedAt).toLocaleDateString()}
</div>
)}
</div>
<button
onClick={handleToggleNsfw}
disabled={saving}
className={`btn btn-sm ${settings?.nsfwEnabled ? 'btn-ghost' : 'btn-primary'}`}
>
{settings?.nsfwEnabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
{/* Mark Account as NSFW - only show if NSFW viewing is enabled */}
{settings?.nsfwEnabled && (
<div className="card" style={{ padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div style={{ flex: 1 }}>
<div style={{
fontWeight: 600,
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<AlertTriangle size={18} />
Mark My Account as NSFW
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
{settings?.isNsfw
? 'Your account is marked as NSFW. All your posts will be hidden from users who haven\'t enabled NSFW content.'
: 'Enable this if you regularly post adult or sensitive content. Your posts will only be visible to users who have enabled NSFW viewing.'}
</div>
</div>
<button
onClick={handleToggleAccountNsfw}
disabled={saving}
style={{
padding: '8px 16px',
borderRadius: 'var(--radius-md)',
border: settings?.isNsfw ? 'none' : '1px solid var(--border)',
background: settings?.isNsfw ? 'var(--error)' : 'var(--background-secondary)',
color: settings?.isNsfw ? 'white' : 'var(--foreground)',
fontWeight: 500,
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
}}
>
{settings?.isNsfw ? 'Remove' : 'Mark NSFW'}
</button>
</div>
</div>
)}
{/* Info box */}
<div style={{
padding: '16px',
background: 'var(--background-secondary)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}>
<div style={{
fontWeight: 600,
marginBottom: '8px',
fontSize: '14px',
}}>
How NSFW filtering works
</div>
<ul style={{
margin: 0,
paddingLeft: '20px',
color: 'var(--foreground-secondary)',
fontSize: '13px',
lineHeight: 1.6,
}}>
<li>Content is marked NSFW at three levels: post, account, or node</li>
<li>If any level is NSFW, the content is hidden from non-verified users</li>
<li>You can mark individual posts as NSFW when composing</li>
<li>NSFW content still syncs across the swarm, but is filtered at display time</li>
</ul>
</div>
</div>
{/* Age Verification Modal */}
{showAgeModal && (
<div style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '16px',
}}>
<div className="card" style={{
maxWidth: '400px',
width: '100%',
padding: '24px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
}}>
<AlertTriangle size={24} color="var(--warning)" />
<h2 style={{ fontSize: '18px', fontWeight: 600 }}>Age Verification</h2>
</div>
<p style={{
color: 'var(--foreground-secondary)',
fontSize: '14px',
marginBottom: '24px',
lineHeight: 1.6,
}}>
NSFW content may include adult themes, nudity, or other sensitive material.
By enabling this setting, you confirm that you are at least 18 years old.
</p>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end',
}}>
<button
onClick={() => setShowAgeModal(false)}
style={{
padding: '10px 20px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--foreground)',
fontWeight: 500,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleAgeConfirm}
disabled={saving}
className="btn btn-primary"
>
{saving ? 'Confirming...' : 'I am 18 or older'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { useAuth } from '@/lib/contexts/AuthContext';
import { TriangleAlert, ShieldAlert } from 'lucide-react';
interface ExportStats {
posts: number;
following: number;
mediaFiles: number;
}
export default function MigrationPage() {
const { user } = useAuth();
// Export state
const [exportPassword, setExportPassword] = useState('');
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportStats, setExportStats] = useState<ExportStats | null>(null);
const handleExport = async () => {
if (!exportPassword) {
setExportError('Please enter your password');
return;
}
setIsExporting(true);
setExportError(null);
try {
const res = await fetch('/api/account/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: exportPassword }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Export failed');
}
setExportStats(data.stats);
// Trigger download
const blob = new Blob([JSON.stringify(data.export, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `synapsis-export-${user?.handle}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
setExportError(error instanceof Error ? error.message : 'Export failed');
} finally {
setIsExporting(false);
}
};
if (!user) {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Export Account</h1>
</div>
</header>
<div className="card" style={{ padding: '24px', textAlign: 'center' }}>
<p style={{ marginBottom: '16px' }}>Please log in to export your account.</p>
<Link href="/login" className="btn btn-primary">Log In</Link>
</div>
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Export Account</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Download a backup of your identity and content
</p>
</div>
</header>
<div className="card" style={{ padding: '24px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
Download Your Data
</h2>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
Download a complete backup of your account including your identity, posts, and media.
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
</p>
<div style={{
background: 'var(--background-tertiary)',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
}}>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
Your export will include:
</div>
<ul style={{
listStyle: 'disc',
paddingLeft: '20px',
color: 'var(--foreground-secondary)',
fontSize: '14px',
lineHeight: 1.6,
}}>
<li>Your DID (Decentralized Identifier)</li>
<li>Your cryptographic keys (encrypted with your password)</li>
<li>Your profile information</li>
<li>All your posts</li>
<li>Your following list</li>
</ul>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Confirm your password
</label>
<input
type="password"
className="input"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
/>
</div>
{exportError && (
<div style={{ color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}>
{exportError}
</div>
)}
{exportStats && (
<div style={{
background: 'var(--success)',
color: '#000',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
}}>
Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references.
</div>
)}
<button
className="btn btn-primary"
onClick={handleExport}
disabled={isExporting || !exportPassword}
style={{ width: '100%' }}
>
{isExporting ? 'Exporting...' : 'Download Export File'}
</button>
<div style={{
marginTop: '20px',
padding: '16px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '8px',
}}>
<div style={{ fontWeight: 600, color: 'var(--error)', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<ShieldAlert size={18} /> Security Warning
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
The export file contains your encrypted private key. Keep this file secure
and never share it with anyone. Anyone with this file and your password
can access your account.
</p>
</div>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { UserX, Globe, Trash2 } from 'lucide-react';
interface BlockedUser {
id: string;
handle: string;
displayName: string | null;
avatarUrl: string | null;
blockedAt: string;
}
interface MutedNode {
domain: string;
mutedAt: string;
}
export default function ModerationSettingsPage() {
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
const [mutedNodes, setMutedNodes] = useState<MutedNode[]>([]);
const [loading, setLoading] = useState(true);
const [newNodeDomain, setNewNodeDomain] = useState('');
const [addingNode, setAddingNode] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [blockedRes, mutedRes] = await Promise.all([
fetch('/api/settings/blocked-users'),
fetch('/api/settings/muted-nodes'),
]);
if (blockedRes.ok) {
const data = await blockedRes.json();
setBlockedUsers(data.blockedUsers || []);
}
if (mutedRes.ok) {
const data = await mutedRes.json();
setMutedNodes(data.mutedNodes || []);
}
} catch (error) {
console.error('Failed to load moderation settings:', error);
} finally {
setLoading(false);
}
};
const handleUnblock = async (userId: string) => {
try {
const res = await fetch(`/api/settings/blocked-users?userId=${userId}`, {
method: 'DELETE',
});
if (res.ok) {
setBlockedUsers(prev => prev.filter(u => u.id !== userId));
}
} catch (error) {
console.error('Failed to unblock user:', error);
}
};
const handleUnmuteNode = async (domain: string) => {
try {
const res = await fetch(`/api/settings/muted-nodes?domain=${encodeURIComponent(domain)}`, {
method: 'DELETE',
});
if (res.ok) {
setMutedNodes(prev => prev.filter(n => n.domain !== domain));
}
} catch (error) {
console.error('Failed to unmute node:', error);
}
};
const handleMuteNode = async (e: React.FormEvent) => {
e.preventDefault();
if (!newNodeDomain.trim() || addingNode) return;
setAddingNode(true);
try {
const res = await fetch('/api/settings/muted-nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: newNodeDomain.trim() }),
});
if (res.ok) {
const data = await res.json();
setMutedNodes(prev => [
{ domain: data.domain, mutedAt: new Date().toISOString() },
...prev.filter(n => n.domain !== data.domain),
]);
setNewNodeDomain('');
}
} catch (error) {
console.error('Failed to mute node:', error);
} finally {
setAddingNode(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Moderation</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage blocked users and muted nodes
</p>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{/* Blocked Users */}
<section>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}>
<UserX size={18} />
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>Blocked Users</h2>
<span style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
background: 'var(--background-secondary)',
padding: '2px 8px',
borderRadius: '10px',
}}>
{blockedUsers.length}
</span>
</div>
{blockedUsers.length === 0 ? (
<div className="card" style={{
padding: '24px',
textAlign: 'center',
color: 'var(--foreground-tertiary)',
}}>
You haven't blocked anyone
</div>
) : (
<div className="card" style={{ overflow: 'hidden' }}>
{blockedUsers.map((user, i) => (
<div
key={user.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < blockedUsers.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<Link
href={`/@${user.handle}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
color: 'var(--foreground)',
textDecoration: 'none',
}}
>
<img
src={user.avatarUrl || '/default-avatar.png'}
alt=""
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
objectFit: 'cover',
}}
/>
<div>
<div style={{ fontWeight: 500 }}>
{user.displayName || user.handle}
</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
@{user.handle}
</div>
</div>
</Link>
<button
onClick={() => handleUnblock(user.id)}
className="btn btn-ghost btn-sm"
>
Unblock
</button>
</div>
))}
</div>
)}
</section>
{/* Muted Nodes */}
<section>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}>
<Globe size={18} />
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>Muted Nodes</h2>
<span style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
background: 'var(--background-secondary)',
padding: '2px 8px',
borderRadius: '10px',
}}>
{mutedNodes.length}
</span>
</div>
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginBottom: '12px',
}}>
Posts from muted nodes won't appear in your feeds or search results.
</p>
<form onSubmit={handleMuteNode} style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
className="input"
placeholder="node.example.com"
value={newNodeDomain}
onChange={(e) => setNewNodeDomain(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-primary"
disabled={!newNodeDomain.trim() || addingNode}
>
{addingNode ? 'Adding...' : 'Mute'}
</button>
</div>
</form>
{mutedNodes.length === 0 ? (
<div className="card" style={{
padding: '24px',
textAlign: 'center',
color: 'var(--foreground-tertiary)',
}}>
No muted nodes
</div>
) : (
<div className="card" style={{ overflow: 'hidden' }}>
{mutedNodes.map((node, i) => (
<div
key={node.domain}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < mutedNodes.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div>
<div style={{ fontWeight: 500 }}>{node.domain}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
Muted {new Date(node.mutedAt).toLocaleDateString()}
</div>
</div>
<button
onClick={() => handleUnmuteNode(node.domain)}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Rocket, Shield, Bell, Bot, Eye, UserX } from 'lucide-react';
export default function SettingsPage() {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Settings</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage your account
</p>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/bots" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bots
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Create and manage automated bots
</div>
</Link>
<Link href="/settings/content" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Eye size={18} />
Content Settings
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
NSFW preferences and content visibility
</div>
</Link>
<Link href="/settings/moderation" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserX size={18} />
Moderation
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Blocked users and muted nodes
</div>
</Link>
<Link href="/settings/migration" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} />
Export Account
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Download a backup of your account and content
</div>
</Link>
<Link href="/settings/security" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Security
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Change password
</div>
</Link>
<div className="card" style={{
display: 'block',
padding: '20px',
opacity: 0.5,
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bell size={18} />
Notifications
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Notification preferences (coming soon)
</div>
</div>
</div>
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
export default function SecuritySettingsPage() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
// Validation
if (newPassword.length < 8) {
setError('New password must be at least 8 characters long');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (currentPassword === newPassword) {
setError('New password cannot be the same as the current password');
return;
}
setIsSubmitting(true);
try {
const res = await fetch('/api/account/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to change password');
}
setSuccess('Password updated successfully');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Security</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage your password and security settings
</p>
</div>
</header>
<div className="card" style={{ padding: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Lock size={20} />
</div>
<div>
<h2 style={{ fontSize: '18px', fontWeight: 600 }}>Change Password</h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Update your password to keep your account secure
</p>
</div>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Current Password
</label>
<input
type="password"
className="input"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
required
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
New Password
</label>
<input
type="password"
className="input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password (min. 8 characters)"
required
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Confirm New Password
</label>
<input
type="password"
className="input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
/>
</div>
{error && (
<div style={{
padding: '12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '8px',
color: 'var(--error)',
fontSize: '14px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<AlertCircle size={16} />
{error}
</div>
)}
{success && (
<div style={{
padding: '12px',
background: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.2)',
borderRadius: '8px',
color: 'var(--success)',
fontSize: '14px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<Check size={16} />
{success}
</div>
)}
<div style={{
marginTop: '20px',
padding: '12px',
background: 'var(--background-tertiary)',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '13px',
color: 'var(--foreground-secondary)'
}}>
<strong>Note:</strong> Changing your password will re-encrypt your identity keys.
Your DID and followers remain unchanged.
</div>
<button
type="submit"
className="btn btn-primary"
disabled={isSubmitting || !currentPassword || !newPassword || !confirmPassword}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}
>
{isSubmitting ? (
'Updating...'
) : (
<>
<Shield size={16} /> Update Password
</>
)}
</button>
</form>
</div>
</div>
);
}