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:
+90
-3
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
@@ -51,6 +53,8 @@ const formatDate = (value: string) => {
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const { showToast } = useToast();
|
||||
const { refreshAccentColor } = useAccentColor();
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||
const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'settings'>('reports');
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
@@ -64,11 +68,14 @@ export default function AdminPage() {
|
||||
longDescription: '',
|
||||
rules: '',
|
||||
bannerUrl: '',
|
||||
logoUrl: '',
|
||||
accentColor: '#00D4AA',
|
||||
});
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/me')
|
||||
@@ -127,6 +134,7 @@ export default function AdminPage() {
|
||||
longDescription: data.longDescription || '',
|
||||
rules: data.rules || '',
|
||||
bannerUrl: data.bannerUrl || '',
|
||||
logoUrl: data.logoUrl || '',
|
||||
accentColor: data.accentColor || '#00D4AA',
|
||||
});
|
||||
} catch {
|
||||
@@ -178,12 +186,13 @@ export default function AdminPage() {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Settings saved!');
|
||||
showToast('Settings saved!', 'success');
|
||||
refreshAccentColor();
|
||||
} else {
|
||||
alert('Failed to save settings.');
|
||||
showToast('Failed to save settings.', 'error');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to save settings.');
|
||||
showToast('Failed to save settings.', 'error');
|
||||
} finally {
|
||||
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 needsReason = action === 'suspend' || action === 'silence';
|
||||
const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
|
||||
@@ -513,6 +557,49 @@ export default function AdminPage() {
|
||||
/>
|
||||
</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>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label>
|
||||
<AutoTextarea
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function PATCH(req: NextRequest) {
|
||||
longDescription: data.longDescription,
|
||||
rules: data.rules,
|
||||
bannerUrl: data.bannerUrl,
|
||||
logoUrl: data.logoUrl,
|
||||
accentColor: data.accentColor,
|
||||
}).returning();
|
||||
} else {
|
||||
@@ -33,6 +34,7 @@ export async function PATCH(req: NextRequest) {
|
||||
longDescription: data.longDescription,
|
||||
rules: data.rules,
|
||||
bannerUrl: data.bannerUrl,
|
||||
logoUrl: data.logoUrl,
|
||||
accentColor: data.accentColor,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nodes, users } from '@/db';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -12,21 +12,42 @@ export async function GET() {
|
||||
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) {
|
||||
return NextResponse.json({
|
||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
||||
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#00D4AA',
|
||||
domain,
|
||||
admins,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(node);
|
||||
return NextResponse.json({ ...node, admins });
|
||||
} catch (error) {
|
||||
console.error('Node info error:', error);
|
||||
return NextResponse.json({
|
||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
||||
admins: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -31,6 +31,8 @@ export const dynamic = 'force-dynamic';
|
||||
// This is appropriate for a social network where all content is user-generated
|
||||
|
||||
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
||||
import { ToastProvider } from '@/lib/contexts/ToastContext';
|
||||
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
|
||||
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -42,9 +44,13 @@ export default function RootLayout({
|
||||
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
<AccentColorProvider>
|
||||
<ToastProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
</ToastProvider>
|
||||
</AccentColorProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,10 +13,12 @@ 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[]>([]);
|
||||
@@ -80,14 +82,14 @@ export default function BotDetailPage() {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.ok) {
|
||||
alert('Post triggered successfully!');
|
||||
showToast('Post triggered successfully!', 'success');
|
||||
fetchBot();
|
||||
} else {
|
||||
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) {
|
||||
alert('Failed to trigger post');
|
||||
showToast('Failed to trigger post', 'error');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -123,7 +125,7 @@ export default function BotDetailPage() {
|
||||
// Build URL and config based on type
|
||||
if (newSource.type === 'brave_news') {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +141,7 @@ export default function BotDetailPage() {
|
||||
};
|
||||
} else if (newSource.type === 'news_api') {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -203,10 +205,10 @@ export default function BotDetailPage() {
|
||||
fetchSources();
|
||||
} else {
|
||||
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) {
|
||||
alert('Failed to add source');
|
||||
showToast('Failed to add source', 'error');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -222,14 +224,14 @@ export default function BotDetailPage() {
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
alert(`Fetched ${data.itemsFetched || 0} items successfully!`);
|
||||
showToast(`Fetched ${data.itemsFetched || 0} items successfully!`, 'success');
|
||||
fetchSources();
|
||||
} else {
|
||||
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) {
|
||||
alert('Failed to fetch content');
|
||||
showToast('Failed to fetch content', 'error');
|
||||
} finally {
|
||||
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.`)) {
|
||||
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
|
||||
.then(() => router.push('/settings/bots'))
|
||||
.catch(() => alert('Failed to delete bot'));
|
||||
.catch(() => showToast('Failed to delete bot', 'error'));
|
||||
}
|
||||
}}
|
||||
className="btn"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { RightSidebar } from './RightSidebar';
|
||||
@@ -17,37 +16,6 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
pathname?.startsWith('/install') ||
|
||||
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) {
|
||||
return (
|
||||
<div style={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/compo
|
||||
import { Bot } from 'lucide-react';
|
||||
import { Post } from '@/lib/types';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
|
||||
@@ -37,6 +38,7 @@ interface PostCardProps {
|
||||
|
||||
export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail }: PostCardProps) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [liked, setLiked] = useState(post.isLiked || false);
|
||||
const [reposted, setReposted] = useState(post.isReposted || false);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
@@ -112,15 +114,15 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
alert('Please log in to report.');
|
||||
showToast('Please log in to report.', 'error');
|
||||
} else {
|
||||
alert('Report failed. Please try again.');
|
||||
showToast('Report failed. Please try again.', 'error');
|
||||
}
|
||||
} else {
|
||||
alert('Report submitted. Thank you.');
|
||||
showToast('Report submitted. Thank you.', 'success');
|
||||
}
|
||||
} catch {
|
||||
alert('Report failed. Please try again.');
|
||||
showToast('Report failed. Please try again.', 'error');
|
||||
} finally {
|
||||
setReporting(false);
|
||||
}
|
||||
@@ -139,10 +141,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
|
||||
onDelete?.(post.id);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to delete post');
|
||||
showToast(data.error || 'Failed to delete post', 'error');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to delete post');
|
||||
showToast('Failed to delete post', 'error');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Admin {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export function RightSidebar() {
|
||||
const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.';
|
||||
@@ -10,6 +17,7 @@ export function RightSidebar() {
|
||||
longDescription: '',
|
||||
rules: '',
|
||||
bannerUrl: '',
|
||||
admins: [] as Admin[],
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -26,6 +34,7 @@ export function RightSidebar() {
|
||||
longDescription: data?.longDescription ?? prev.longDescription,
|
||||
rules: data?.rules ?? prev.rules,
|
||||
bannerUrl: data?.bannerUrl ?? prev.bannerUrl,
|
||||
admins: data?.admins ?? [],
|
||||
}));
|
||||
})
|
||||
.catch(() => { })
|
||||
@@ -101,6 +110,39 @@ export function RightSidebar() {
|
||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
||||
Running Synapsis v0.1.0
|
||||
</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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
@@ -10,6 +11,18 @@ import { formatFullHandle } from '@/lib/utils/handle';
|
||||
export function Sidebar() {
|
||||
const { user, isAdmin } = useAuth();
|
||||
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
|
||||
const isHome = pathname === '/';
|
||||
@@ -17,7 +30,11 @@ export function Sidebar() {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<Link href="/" className="logo">
|
||||
<Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
|
||||
{customLogoUrl ? (
|
||||
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
|
||||
)}
|
||||
</Link>
|
||||
<nav>
|
||||
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const nodes = pgTable('nodes', {
|
||||
longDescription: text('long_description'),
|
||||
rules: text('rules'),
|
||||
bannerUrl: text('banner_url'),
|
||||
logoUrl: text('logo_url'),
|
||||
accentColor: text('accent_color').default('#FFFFFF'),
|
||||
publicKey: text('public_key'),
|
||||
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