feat(db,admin): Add logo URL support and refactor bot schema with owner relationships
- Add logo_url column to nodes table for custom branding - Refactor bots table to use owner_id foreign key relationship with users - Add is_bot and bot_owner_id columns to users table for bot account tracking - Add bot_id foreign key to posts table for bot-generated content tracking - Create database indexes on owner_id, bot_id, is_bot, and bot_owner_id for query performance - Remove bot handle, bio, avatar_url, and cryptographic keys from bots table (moved to user accounts) - Update bot_content_sources schema and remove fetch_interval_minutes column - Fix bot_content_items foreign key constraint with set null on delete - Remove hardcoded NODE_NAME and NODE_DESCRIPTION from environment configuration - Update admin panel, bot settings, and layout components to support new schema - Add AccentColorContext and ToastContext for improved UI state management - Update PostCard, Sidebar, RightSidebar, and LayoutWrapper components for context integration
This commit is contained in:
@@ -12,8 +12,6 @@ AUTH_SECRET=your-secret-key-here
|
|||||||
|
|
||||||
# Node Configuration (Required)
|
# Node Configuration (Required)
|
||||||
NEXT_PUBLIC_NODE_DOMAIN=your-domain.com
|
NEXT_PUBLIC_NODE_DOMAIN=your-domain.com
|
||||||
NEXT_PUBLIC_NODE_NAME=My Synapsis Node
|
|
||||||
NEXT_PUBLIC_NODE_DESCRIPTION=A federated social network node
|
|
||||||
|
|
||||||
# Admin Users (Required)
|
# Admin Users (Required)
|
||||||
# Comma-separated list of email addresses that have admin access
|
# Comma-separated list of email addresses that have admin access
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
ALTER TABLE "bots" DROP CONSTRAINT "bots_handle_unique";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bot_content_items" DROP CONSTRAINT "bot_content_items_post_id_posts_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP INDEX "bots_handle_idx";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bot_content_sources" ADD COLUMN "source_config" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" ADD COLUMN "owner_id" uuid NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "nodes" ADD COLUMN "logo_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "posts" ADD COLUMN "bot_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "is_bot" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "bot_owner_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "bot_content_items" ADD CONSTRAINT "bot_content_items_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" ADD CONSTRAINT "bots_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "posts" ADD CONSTRAINT "posts_bot_id_bots_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."bots"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_bot_owner_id_users_id_fk" FOREIGN KEY ("bot_owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "bots_owner_id_idx" ON "bots" USING btree ("owner_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "posts_bot_id_idx" ON "posts" USING btree ("bot_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_is_bot_idx" ON "users" USING btree ("is_bot");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_bot_owner_idx" ON "users" USING btree ("bot_owner_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "bot_content_sources" DROP COLUMN "fetch_interval_minutes";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" DROP COLUMN "handle";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" DROP COLUMN "bio";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" DROP COLUMN "avatar_url";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" DROP COLUMN "public_key";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bots" DROP COLUMN "private_key_encrypted";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1769153858324,
|
"when": 1769153858324,
|
||||||
"tag": "0004_add_source_config",
|
"tag": "0004_add_source_config",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769367465905,
|
||||||
|
"tag": "0002_add_logo_url",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+90
-3
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
|
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,6 +53,8 @@ const formatDate = (value: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { refreshAccentColor } = useAccentColor();
|
||||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||||
const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'settings'>('reports');
|
const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'settings'>('reports');
|
||||||
const [reports, setReports] = useState<Report[]>([]);
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
@@ -64,11 +68,14 @@ export default function AdminPage() {
|
|||||||
longDescription: '',
|
longDescription: '',
|
||||||
rules: '',
|
rules: '',
|
||||||
bannerUrl: '',
|
bannerUrl: '',
|
||||||
|
logoUrl: '',
|
||||||
accentColor: '#00D4AA',
|
accentColor: '#00D4AA',
|
||||||
});
|
});
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||||
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
||||||
|
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||||
|
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/me')
|
fetch('/api/admin/me')
|
||||||
@@ -127,6 +134,7 @@ export default function AdminPage() {
|
|||||||
longDescription: data.longDescription || '',
|
longDescription: data.longDescription || '',
|
||||||
rules: data.rules || '',
|
rules: data.rules || '',
|
||||||
bannerUrl: data.bannerUrl || '',
|
bannerUrl: data.bannerUrl || '',
|
||||||
|
logoUrl: data.logoUrl || '',
|
||||||
accentColor: data.accentColor || '#00D4AA',
|
accentColor: data.accentColor || '#00D4AA',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -178,12 +186,13 @@ export default function AdminPage() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert('Settings saved!');
|
showToast('Settings saved!', 'success');
|
||||||
|
refreshAccentColor();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to save settings.');
|
showToast('Failed to save settings.', 'error');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Failed to save settings.');
|
showToast('Failed to save settings.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
@@ -224,6 +233,41 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLogoUploadError(null);
|
||||||
|
setIsUploadingLogo(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const res = await fetch('/api/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
throw new Error(data.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSettings = {
|
||||||
|
...nodeSettings,
|
||||||
|
logoUrl: data.media?.url || data.url,
|
||||||
|
};
|
||||||
|
setNodeSettings(nextSettings);
|
||||||
|
await handleSaveSettings(nextSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logo upload failed', error);
|
||||||
|
setLogoUploadError('Upload failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsUploadingLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => {
|
const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => {
|
||||||
const needsReason = action === 'suspend' || action === 'silence';
|
const needsReason = action === 'suspend' || action === 'silence';
|
||||||
const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
|
const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
|
||||||
@@ -513,6 +557,49 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Logo</label>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
|
||||||
|
Replaces the default logo in the sidebar. Max width: 200px.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<label className="btn btn-ghost btn-sm">
|
||||||
|
{isUploadingLogo ? 'Uploading...' : 'Upload logo'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
disabled={isUploadingLogo}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{nodeSettings.logoUrl && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const nextSettings = { ...nodeSettings, logoUrl: '' };
|
||||||
|
setNodeSettings(nextSettings);
|
||||||
|
await handleSaveSettings(nextSettings);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove logo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{logoUploadError && (
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--danger)' }}>{logoUploadError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{nodeSettings.logoUrl && (
|
||||||
|
<div style={{ marginTop: '8px', padding: '12px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background-secondary)' }}>
|
||||||
|
<img
|
||||||
|
src={nodeSettings.logoUrl}
|
||||||
|
alt="Custom logo"
|
||||||
|
style={{ maxWidth: '200px', maxHeight: '60px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label>
|
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label>
|
||||||
<AutoTextarea
|
<AutoTextarea
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
longDescription: data.longDescription,
|
longDescription: data.longDescription,
|
||||||
rules: data.rules,
|
rules: data.rules,
|
||||||
bannerUrl: data.bannerUrl,
|
bannerUrl: data.bannerUrl,
|
||||||
|
logoUrl: data.logoUrl,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
}).returning();
|
}).returning();
|
||||||
} else {
|
} else {
|
||||||
@@ -33,6 +34,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
longDescription: data.longDescription,
|
longDescription: data.longDescription,
|
||||||
rules: data.rules,
|
rules: data.rules,
|
||||||
bannerUrl: data.bannerUrl,
|
bannerUrl: data.bannerUrl,
|
||||||
|
logoUrl: data.logoUrl,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { nodes } from '@/db';
|
import { nodes, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -12,21 +12,42 @@ export async function GET() {
|
|||||||
where: eq(nodes.domain, domain),
|
where: eq(nodes.domain, domain),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch admin users based on ADMIN_EMAILS env var
|
||||||
|
const adminEmails = (process.env.ADMIN_EMAILS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(e => e.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let admins: { handle: string; displayName: string | null; avatarUrl: string | null }[] = [];
|
||||||
|
if (adminEmails.length > 0) {
|
||||||
|
const adminUsers = await db
|
||||||
|
.select({
|
||||||
|
handle: users.handle,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.email, adminEmails));
|
||||||
|
admins = adminUsers;
|
||||||
|
}
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
||||||
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#00D4AA',
|
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#00D4AA',
|
||||||
domain,
|
domain,
|
||||||
|
admins,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(node);
|
return NextResponse.json({ ...node, admins });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Node info error:', error);
|
console.error('Node info error:', error);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
||||||
|
admins: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const dynamic = 'force-dynamic';
|
|||||||
// This is appropriate for a social network where all content is user-generated
|
// This is appropriate for a social network where all content is user-generated
|
||||||
|
|
||||||
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
||||||
|
import { ToastProvider } from '@/lib/contexts/ToastContext';
|
||||||
|
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
|
||||||
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -42,9 +44,13 @@ export default function RootLayout({
|
|||||||
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<AccentColorProvider>
|
||||||
|
<ToastProvider>
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
{children}
|
{children}
|
||||||
</LayoutWrapper>
|
</LayoutWrapper>
|
||||||
|
</ToastProvider>
|
||||||
|
</AccentColorProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import { useParams, useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { Bot, Play, Pause, Rss, Activity, Settings, Sparkles, Clock, Trash2, Pencil } from 'lucide-react';
|
import { Bot, Play, Pause, Rss, Activity, Settings, Sparkles, Clock, Trash2, Pencil } from 'lucide-react';
|
||||||
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
|
|
||||||
export default function BotDetailPage() {
|
export default function BotDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showToast } = useToast();
|
||||||
const botId = params.id as string;
|
const botId = params.id as string;
|
||||||
const [bot, setBot] = useState<any>(null);
|
const [bot, setBot] = useState<any>(null);
|
||||||
const [sources, setSources] = useState<any[]>([]);
|
const [sources, setSources] = useState<any[]>([]);
|
||||||
@@ -80,14 +82,14 @@ export default function BotDetailPage() {
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('Post triggered successfully!');
|
showToast('Post triggered successfully!', 'success');
|
||||||
fetchBot();
|
fetchBot();
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(`Failed to trigger post: ${data.error || 'Unknown error'}`);
|
showToast(`Failed to trigger post: ${data.error || 'Unknown error'}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to trigger post');
|
showToast('Failed to trigger post', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -123,7 +125,7 @@ export default function BotDetailPage() {
|
|||||||
// Build URL and config based on type
|
// Build URL and config based on type
|
||||||
if (newSource.type === 'brave_news') {
|
if (newSource.type === 'brave_news') {
|
||||||
if (!newSource.braveQuery || !newSource.apiKey) {
|
if (!newSource.braveQuery || !newSource.apiKey) {
|
||||||
alert('Search query and API key are required for Brave News');
|
showToast('Search query and API key are required for Brave News', 'error');
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,7 +141,7 @@ export default function BotDetailPage() {
|
|||||||
};
|
};
|
||||||
} else if (newSource.type === 'news_api') {
|
} else if (newSource.type === 'news_api') {
|
||||||
if (!newSource.newsQuery || !newSource.apiKey) {
|
if (!newSource.newsQuery || !newSource.apiKey) {
|
||||||
alert('Search query and API key are required for News API');
|
showToast('Search query and API key are required for News API', 'error');
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,10 +205,10 @@ export default function BotDetailPage() {
|
|||||||
fetchSources();
|
fetchSources();
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(`Failed to add source: ${data.error || 'Unknown error'}`);
|
showToast(`Failed to add source: ${data.error || 'Unknown error'}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to add source');
|
showToast('Failed to add source', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -222,14 +224,14 @@ export default function BotDetailPage() {
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(`Fetched ${data.itemsFetched || 0} items successfully!`);
|
showToast(`Fetched ${data.itemsFetched || 0} items successfully!`, 'success');
|
||||||
fetchSources();
|
fetchSources();
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(`Failed to fetch content: ${data.error || 'Unknown error'}`);
|
showToast(`Failed to fetch content: ${data.error || 'Unknown error'}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to fetch content');
|
showToast('Failed to fetch content', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -715,7 +717,7 @@ export default function BotDetailPage() {
|
|||||||
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
|
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
|
||||||
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
|
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
|
||||||
.then(() => router.push('/settings/bots'))
|
.then(() => router.push('/settings/bots'))
|
||||||
.catch(() => alert('Failed to delete bot'));
|
.catch(() => showToast('Failed to delete bot', 'error'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="btn"
|
className="btn"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { RightSidebar } from './RightSidebar';
|
import { RightSidebar } from './RightSidebar';
|
||||||
@@ -17,37 +16,6 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||||||
pathname?.startsWith('/install') ||
|
pathname?.startsWith('/install') ||
|
||||||
pathname?.startsWith('/admin');
|
pathname?.startsWith('/admin');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const applyAccent = (color?: string | null) => {
|
|
||||||
if (!color) return;
|
|
||||||
const cleaned = color.trim();
|
|
||||||
const normalized = cleaned.startsWith('#') ? cleaned : `#${cleaned}`;
|
|
||||||
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(normalized);
|
|
||||||
if (!hexMatch) return;
|
|
||||||
|
|
||||||
const hex = hexMatch[1];
|
|
||||||
const r = parseInt(hex.slice(0, 2), 16);
|
|
||||||
const g = parseInt(hex.slice(2, 4), 16);
|
|
||||||
const b = parseInt(hex.slice(4, 6), 16);
|
|
||||||
|
|
||||||
const mix = (channel: number, target: number, amount: number) =>
|
|
||||||
Math.round(channel + (target - channel) * amount);
|
|
||||||
|
|
||||||
const hover = `rgb(${mix(r, 255, 0.12)}, ${mix(g, 255, 0.12)}, ${mix(b, 255, 0.12)})`;
|
|
||||||
const muted = `rgba(${r}, ${g}, ${b}, 0.12)`;
|
|
||||||
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.style.setProperty('--accent', `#${hex}`);
|
|
||||||
root.style.setProperty('--accent-hover', hover);
|
|
||||||
root.style.setProperty('--accent-muted', muted);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetch('/api/node', { cache: 'no-store' })
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => applyAccent(data?.accentColor))
|
|
||||||
.catch(() => { });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/compo
|
|||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ interface PostCardProps {
|
|||||||
|
|
||||||
export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail }: PostCardProps) {
|
export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail }: PostCardProps) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
const [liked, setLiked] = useState(post.isLiked || false);
|
const [liked, setLiked] = useState(post.isLiked || false);
|
||||||
const [reposted, setReposted] = useState(post.isReposted || false);
|
const [reposted, setReposted] = useState(post.isReposted || false);
|
||||||
const [reporting, setReporting] = useState(false);
|
const [reporting, setReporting] = useState(false);
|
||||||
@@ -112,15 +114,15 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
alert('Please log in to report.');
|
showToast('Please log in to report.', 'error');
|
||||||
} else {
|
} else {
|
||||||
alert('Report failed. Please try again.');
|
showToast('Report failed. Please try again.', 'error');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Report submitted. Thank you.');
|
showToast('Report submitted. Thank you.', 'success');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Report failed. Please try again.');
|
showToast('Report failed. Please try again.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setReporting(false);
|
setReporting(false);
|
||||||
}
|
}
|
||||||
@@ -139,10 +141,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
|
|||||||
onDelete?.(post.id);
|
onDelete?.(post.id);
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Failed to delete post');
|
showToast(data.error || 'Failed to delete post', 'error');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Failed to delete post');
|
showToast('Failed to delete post', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Admin {
|
||||||
|
handle: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.';
|
const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.';
|
||||||
@@ -10,6 +17,7 @@ export function RightSidebar() {
|
|||||||
longDescription: '',
|
longDescription: '',
|
||||||
rules: '',
|
rules: '',
|
||||||
bannerUrl: '',
|
bannerUrl: '',
|
||||||
|
admins: [] as Admin[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -26,6 +34,7 @@ export function RightSidebar() {
|
|||||||
longDescription: data?.longDescription ?? prev.longDescription,
|
longDescription: data?.longDescription ?? prev.longDescription,
|
||||||
rules: data?.rules ?? prev.rules,
|
rules: data?.rules ?? prev.rules,
|
||||||
bannerUrl: data?.bannerUrl ?? prev.bannerUrl,
|
bannerUrl: data?.bannerUrl ?? prev.bannerUrl,
|
||||||
|
admins: data?.admins ?? [],
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => { })
|
.catch(() => { })
|
||||||
@@ -101,6 +110,39 @@ export function RightSidebar() {
|
|||||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
||||||
Running Synapsis v0.1.0
|
Running Synapsis v0.1.0
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{nodeInfo.admins.length > 0 && (
|
||||||
|
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-hover)' }}>
|
||||||
|
<h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', color: 'var(--foreground-tertiary)', marginBottom: '12px', letterSpacing: '0.05em' }}>
|
||||||
|
Admins
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{nodeInfo.admins.map((admin) => (
|
||||||
|
<Link
|
||||||
|
key={admin.handle}
|
||||||
|
href={`/${admin.handle}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={admin.avatarUrl || `https://api.dicebear.com/7.x/identicon/svg?seed=${admin.handle}`}
|
||||||
|
alt={admin.displayName || admin.handle}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
style={{ borderRadius: '50%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '14px' }}>
|
||||||
|
{admin.displayName || admin.handle}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '12px' }}>
|
||||||
|
@{admin.handle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
@@ -10,6 +11,18 @@ import { formatFullHandle } from '@/lib/utils/handle';
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { user, isAdmin } = useAuth();
|
const { user, isAdmin } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [customLogoUrl, setCustomLogoUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/node')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.logoUrl) {
|
||||||
|
setCustomLogoUrl(data.logoUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Home is exact match
|
// Home is exact match
|
||||||
const isHome = pathname === '/';
|
const isHome = pathname === '/';
|
||||||
@@ -17,7 +30,11 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<Link href="/" className="logo">
|
<Link href="/" className="logo">
|
||||||
|
{customLogoUrl ? (
|
||||||
|
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
<Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
|
<Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<nav>
|
<nav>
|
||||||
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const nodes = pgTable('nodes', {
|
|||||||
longDescription: text('long_description'),
|
longDescription: text('long_description'),
|
||||||
rules: text('rules'),
|
rules: text('rules'),
|
||||||
bannerUrl: text('banner_url'),
|
bannerUrl: text('banner_url'),
|
||||||
|
logoUrl: text('logo_url'),
|
||||||
accentColor: text('accent_color').default('#FFFFFF'),
|
accentColor: text('accent_color').default('#FFFFFF'),
|
||||||
publicKey: text('public_key'),
|
publicKey: text('public_key'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AccentColorContextType {
|
||||||
|
accentColor: string;
|
||||||
|
refreshAccentColor: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccentColorContext = createContext<AccentColorContextType | null>(null);
|
||||||
|
|
||||||
|
function applyAccentColor(color: string) {
|
||||||
|
const cleaned = color.trim();
|
||||||
|
const normalized = cleaned.startsWith('#') ? cleaned : `#${cleaned}`;
|
||||||
|
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(normalized);
|
||||||
|
if (!hexMatch) return;
|
||||||
|
|
||||||
|
const hex = hexMatch[1];
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16);
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16);
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16);
|
||||||
|
|
||||||
|
const mix = (channel: number, target: number, amount: number) =>
|
||||||
|
Math.round(channel + (target - channel) * amount);
|
||||||
|
|
||||||
|
const hover = `rgb(${mix(r, 255, 0.12)}, ${mix(g, 255, 0.12)}, ${mix(b, 255, 0.12)})`;
|
||||||
|
const muted = `rgba(${r}, ${g}, ${b}, 0.12)`;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--accent', `#${hex}`);
|
||||||
|
root.style.setProperty('--accent-hover', hover);
|
||||||
|
root.style.setProperty('--accent-muted', muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccentColorProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [accentColor, setAccentColor] = useState('#00D4AA');
|
||||||
|
|
||||||
|
const refreshAccentColor = useCallback(() => {
|
||||||
|
fetch('/api/node', { cache: 'no-store' })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.accentColor) {
|
||||||
|
setAccentColor(data.accentColor);
|
||||||
|
applyAccentColor(data.accentColor);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAccentColor();
|
||||||
|
}, [refreshAccentColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccentColorContext.Provider value={{ accentColor, refreshAccentColor }}>
|
||||||
|
{children}
|
||||||
|
</AccentColorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccentColor() {
|
||||||
|
const context = useContext(AccentColorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAccentColor must be used within an AccentColorProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toasts: Toast[];
|
||||||
|
showToast: (message: string, type?: ToastType) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
setToasts(prev => [...prev, { id, message, type }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, 3500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastContainer({ toasts, removeToast }: { toasts: Toast[]; removeToast: (id: string) => void }) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '24px',
|
||||||
|
right: '24px',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: toast.type === 'error'
|
||||||
|
? 'var(--danger)'
|
||||||
|
: toast.type === 'success'
|
||||||
|
? 'var(--accent)'
|
||||||
|
: 'var(--background-secondary)',
|
||||||
|
color: toast.type === 'error' || toast.type === 'success'
|
||||||
|
? '#fff'
|
||||||
|
: 'var(--foreground)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
maxWidth: '320px',
|
||||||
|
animation: 'toastSlideIn 0.2s ease-out',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes toastSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user