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:
AskIt
2026-01-25 20:15:35 +01:00
parent d36c1c93e5
commit b9316552c2
16 changed files with 3386 additions and 61 deletions
-2
View File
@@ -12,8 +12,6 @@ AUTH_SECRET=your-secret-key-here
# Node Configuration (Required)
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)
# Comma-separated list of email addresses that have admin access
+24
View File
@@ -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
+7
View File
@@ -15,6 +15,13 @@
"when": 1769153858324,
"tag": "0004_add_source_config",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1769367465905,
"tag": "0002_add_logo_url",
"breakpoints": true
}
]
}
+90 -3
View File
@@ -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
+2
View File
@@ -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(),
})
+24 -3
View File
@@ -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
View File
@@ -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 -11
View File
@@ -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"
-32
View File
@@ -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={{
+8 -6
View File
@@ -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);
}
+42
View File
@@ -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>
);
+18 -1
View File
@@ -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' : ''}`}>
+1
View File
@@ -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(),
+67
View File
@@ -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;
}
+108
View File
@@ -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>
);
}