Initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user