feat: implement authentication context, admin node settings, and core layout components

This commit is contained in:
Christopher
2026-01-22 04:26:23 -08:00
parent c5f2d9c128
commit 7213f08b64
17 changed files with 698 additions and 462 deletions
+4
View File
@@ -1,4 +1,8 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
dotenv.config();
export default defineConfig({ export default defineConfig({
schema: './src/db/schema.ts', schema: './src/db/schema.ts',
+14
View File
@@ -30,6 +30,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.4", "eslint-config-next": "16.1.4",
@@ -3835,6 +3836,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/drizzle-kit": { "node_modules/drizzle-kit": {
"version": "0.31.8", "version": "0.31.8",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
+1
View File
@@ -35,6 +35,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.4", "eslint-config-next": "16.1.4",
+1 -42
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { ArrowLeftIcon, CalendarIcon, HeartIcon, RepeatIcon, MessageIcon, FlagIcon } from '@/components/Icons';
interface User { interface User {
id: string; id: string;
@@ -43,49 +44,7 @@ interface Post {
} }
// Icons // Icons
const ArrowLeftIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
);
const CalendarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
const HeartIcon = ({ filled }: { filled?: boolean }) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
const RepeatIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
const MessageIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
);
const FlagIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 5v16" />
<path d="M5 5h11l-1 4 1 4H5" />
</svg>
);
function UserRow({ user }: { user: UserSummary }) { function UserRow({ user }: { user: UserSummary }) {
return ( return (
+134 -1
View File
@@ -49,12 +49,20 @@ const formatDate = (value: string) => {
export default function AdminPage() { export default function AdminPage() {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null); const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports'); const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'settings'>('reports');
const [reports, setReports] = useState<Report[]>([]); const [reports, setReports] = useState<Report[]>([]);
const [posts, setPosts] = useState<AdminPost[]>([]); const [posts, setPosts] = useState<AdminPost[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open'); const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
const [nodeSettings, setNodeSettings] = useState({
name: '',
description: '',
longDescription: '',
rules: '',
bannerUrl: '',
});
const [savingSettings, setSavingSettings] = useState(false);
useEffect(() => { useEffect(() => {
fetch('/api/admin/me') fetch('/api/admin/me')
@@ -102,11 +110,31 @@ export default function AdminPage() {
} }
}; };
const loadNodeSettings = async () => {
setLoading(true);
try {
const res = await fetch('/api/node');
const data = await res.json();
setNodeSettings({
name: data.name || '',
description: data.description || '',
longDescription: data.longDescription || '',
rules: data.rules || '',
bannerUrl: data.bannerUrl || '',
});
} catch {
// error
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
if (!isAdmin) return; if (!isAdmin) return;
if (tab === 'reports') loadReports(); if (tab === 'reports') loadReports();
if (tab === 'posts') loadPosts(); if (tab === 'posts') loadPosts();
if (tab === 'users') loadUsers(); if (tab === 'users') loadUsers();
if (tab === 'settings') loadNodeSettings();
}, [tab, isAdmin, reportStatus]); }, [tab, isAdmin, reportStatus]);
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => { const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
@@ -133,6 +161,26 @@ export default function AdminPage() {
} }
}; };
const handleSaveSettings = async () => {
setSavingSettings(true);
try {
const res = await fetch('/api/admin/node', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodeSettings),
});
if (res.ok) {
alert('Settings saved!');
} else {
alert('Failed to save settings.');
}
} catch {
alert('Failed to save settings.');
} finally {
setSavingSettings(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):') || '' : '';
@@ -195,6 +243,9 @@ export default function AdminPage() {
<button className={`admin-tab ${tab === 'users' ? 'active' : ''}`} onClick={() => setTab('users')}> <button className={`admin-tab ${tab === 'users' ? 'active' : ''}`} onClick={() => setTab('users')}>
Users Users
</button> </button>
<button className={`admin-tab ${tab === 'settings' ? 'active' : ''}`} onClick={() => setTab('settings')}>
Settings
</button>
</div> </div>
{tab === 'reports' && ( {tab === 'reports' && (
@@ -383,6 +434,88 @@ export default function AdminPage() {
)} )}
</div> </div>
)} )}
{tab === 'settings' && (
<div className="admin-card">
<div style={{ fontWeight: 600, marginBottom: '16px', fontSize: '16px' }}>Node Settings</div>
{loading ? (
<div className="admin-empty">Loading settings...</div>
) : (
<div style={{ display: 'grid', gap: '16px', maxWidth: '600px' }}>
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Node Name</label>
<input
className="input"
value={nodeSettings.name}
onChange={e => setNodeSettings({ ...nodeSettings, name: e.target.value })}
placeholder="My Synapsis Node"
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label>
<textarea
className="input"
value={nodeSettings.description}
onChange={e => setNodeSettings({ ...nodeSettings, description: e.target.value })}
placeholder="A brief tagline for your node."
rows={2}
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Banner / Logo URL</label>
<input
className="input"
value={nodeSettings.bannerUrl}
onChange={e => setNodeSettings({ ...nodeSettings, bannerUrl: e.target.value })}
placeholder="https://"
/>
{nodeSettings.bannerUrl && (
<div style={{ marginTop: '8px', height: '120px', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--border)', position: 'relative' }}>
<div style={{
position: 'absolute', inset: 0,
background: `url(${nodeSettings.bannerUrl}) center/cover no-repeat`
}} />
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(to bottom, transparent, var(--background-secondary))'
}} />
</div>
)}
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Long Description (About)</label>
<textarea
className="input"
value={nodeSettings.longDescription}
onChange={e => setNodeSettings({ ...nodeSettings, longDescription: e.target.value })}
placeholder="Detailed information about your node/community."
rows={5}
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Rules</label>
<textarea
className="input"
value={nodeSettings.rules}
onChange={e => setNodeSettings({ ...nodeSettings, rules: e.target.value })}
placeholder="Community rules and guidelines."
rows={5}
/>
</div>
<div style={{ paddingTop: '8px' }}>
<button className="btn btn-primary" onClick={handleSaveSettings} disabled={savingSettings}>
{savingSettings ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
)}
</div>
)}
</div> </div>
); );
} }
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { nodes } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAdmin } from '@/lib/auth/admin';
export async function PATCH(req: NextRequest) {
try {
await requireAdmin();
const data = await req.json();
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
if (!node) {
[node] = await db.insert(nodes).values({
domain,
name: data.name || process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: data.description,
longDescription: data.longDescription,
rules: data.rules,
bannerUrl: data.bannerUrl,
accentColor: data.accentColor,
}).returning();
} else {
[node] = await db.update(nodes)
.set({
name: data.name,
description: data.description,
longDescription: data.longDescription,
rules: data.rules,
bannerUrl: data.bannerUrl,
updatedAt: new Date(),
})
.where(eq(nodes.id, node.id))
.returning();
}
return NextResponse.json({ node });
} catch (error) {
console.error('Update node settings error:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { nodes } from '@/db';
import { eq } from 'drizzle-orm';
export async function GET() {
try {
if (!db) return NextResponse.json({});
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
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,
});
}
return NextResponse.json(node);
} 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.',
});
}
}
+1 -42
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
interface User { interface User {
id: string; id: string;
@@ -28,49 +29,7 @@ interface Post {
media?: MediaItem[]; media?: MediaItem[];
} }
const SearchIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const TrendingIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
);
const UsersIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const HeartIcon = ({ filled }: { filled?: boolean }) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
const RepeatIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
const MessageIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
);
function PostCard({ post }: { post: Post }) { function PostCard({ post }: { post: Post }) {
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
+10 -1
View File
@@ -30,6 +30,9 @@ 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 { LayoutWrapper } from '@/components/LayoutWrapper';
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@@ -37,7 +40,13 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}> <html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
<body>{children}</body> <body>
<AuthProvider>
<LayoutWrapper>
{children}
</LayoutWrapper>
</AuthProvider>
</body>
</html> </html>
); );
} }
+20 -156
View File
@@ -1,167 +1,31 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { BellIcon } from '@/components/Icons';
import Link from 'next/link';
type Notification = {
id: string;
type: 'follow' | 'like' | 'repost' | 'mention';
createdAt: string;
readAt?: string | null;
actor?: {
id: string;
handle: string;
displayName?: string | null;
avatarUrl?: string | null;
} | null;
post?: {
id: string;
content: string;
} | null;
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
};
const buildMessage = (item: Notification) => {
const actorName = item.actor?.displayName || item.actor?.handle || 'Someone';
switch (item.type) {
case 'follow':
return `${actorName} followed you`;
case 'like':
return `${actorName} liked your post`;
case 'repost':
return `${actorName} reposted your post`;
case 'mention':
return `${actorName} mentioned you`;
default:
return `${actorName} sent a notification`;
}
};
export default function NotificationsPage() { export default function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [polling, setPolling] = useState(true);
const loadNotifications = async () => {
try {
const res = await fetch('/api/notifications?limit=50', { cache: 'no-store' });
const data = await res.json();
setNotifications(data.notifications || []);
} catch {
setNotifications([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadNotifications();
}, []);
useEffect(() => {
if (!polling) return;
const interval = setInterval(loadNotifications, 15000);
return () => clearInterval(interval);
}, [polling]);
const markAllRead = async () => {
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true }),
});
loadNotifications();
};
const markRead = async (id: string) => {
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [id] }),
});
setNotifications((prev) =>
prev.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item))
);
};
const unreadCount = notifications.filter((item) => !item.readAt).length;
return ( return (
<div className="notifications-shell"> <div className="notifications-page">
<header className="notifications-header"> <header style={{
<div> padding: '16px',
<h1>Notifications</h1> borderBottom: '1px solid var(--border)',
<p>{unreadCount} unread</p> background: 'var(--background)',
</div> position: 'sticky',
<div className="notifications-actions"> top: 0,
<button className="btn btn-ghost btn-sm" onClick={() => setPolling(!polling)}> zIndex: 10,
{polling ? 'Pause polling' : 'Resume polling'} backdropFilter: 'blur(12px)',
</button> }}>
<button className="btn btn-primary btn-sm" onClick={markAllRead} disabled={notifications.length === 0}> <h1 style={{ fontSize: '18px', fontWeight: 600 }}>Notifications</h1>
Mark all read
</button>
</div>
</header> </header>
{loading ? ( <div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<div className="notifications-empty">Loading notifications...</div> <div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'center' }}>
) : notifications.length === 0 ? ( <div style={{ width: 40, height: 40, background: 'var(--background-secondary)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="notifications-empty">No notifications yet.</div> <BellIcon />
) : ( </div>
<div className="notifications-list">
{notifications.map((item) => (
<div
key={item.id}
className={`notification-row ${item.readAt ? 'read' : 'unread'}`}
role="button"
tabIndex={0}
onClick={() => markRead(item.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
markRead(item.id);
}
}}
>
<div className="notification-avatar">
{item.actor?.avatarUrl ? (
<img src={item.actor.avatarUrl} alt={item.actor.handle} />
) : (
(item.actor?.displayName || item.actor?.handle || '?').charAt(0).toUpperCase()
)}
</div>
<div className="notification-content">
<div className="notification-message">{buildMessage(item)}</div>
{item.post?.content && (
<div className="notification-post">{item.post.content.slice(0, 120)}</div>
)}
<div className="notification-meta">
<span>{formatTime(item.createdAt)}</span>
{item.actor?.handle && (
<Link href={`/@${item.actor.handle}`} className="notification-link">
@{item.actor.handle}
</Link>
)}
</div>
</div>
</div>
))}
</div> </div>
)} <h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px', color: 'var(--foreground)' }}>No notifications yet</h3>
<p style={{ fontSize: '14px' }}>When you get interactions, they'll show up here.</p>
</div>
</div> </div>
); );
} }
+73 -220
View File
@@ -2,12 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/contexts/AuthContext';
const ShieldIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
interface User { interface User {
id: string; id: string;
@@ -45,33 +40,7 @@ interface Post {
} }
// Icons as simple SVG components // Icons as simple SVG components
const HomeIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
const SearchIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const BellIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
const UserIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
const HeartIcon = ({ filled }: { filled?: boolean }) => ( const HeartIcon = ({ filled }: { filled?: boolean }) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
@@ -101,32 +70,7 @@ const FlagIcon = () => (
</svg> </svg>
); );
const SynapsisLogo = () => (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
<path d="M9 13a4.5 4.5 0 0 0 3-4" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M12 13h4" />
<path d="M12 18h6a2 2 0 0 1 2 2v1" />
<path d="M12 8h8" />
<path d="M16 8V5a2 2 0 0 1 2-2" />
<circle cx="16" cy="13" r=".5" fill="currentColor" />
<circle cx="18" cy="3" r=".5" fill="currentColor" />
<circle cx="20" cy="21" r=".5" fill="currentColor" />
<circle cx="20" cy="8" r=".5" fill="currentColor" />
</svg>
);
function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) => void; onRepost: (id: string) => void }) { function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) => void; onRepost: (id: string) => void }) {
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
@@ -372,8 +316,7 @@ function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) =>
} }
export default function Home() { export default function Home() {
const [user, setUser] = useState<User | null>(null); const { user } = useAuth();
const [isAdmin, setIsAdmin] = useState(false);
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest'); const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
@@ -405,20 +348,6 @@ export default function Home() {
} }
}; };
useEffect(() => {
// Check auth status
fetch('/api/auth/me')
.then(res => res.json())
.then(data => setUser(data.user))
.catch(() => { });
// Check admin status
fetch('/api/admin/me')
.then(res => res.json())
.then(data => setIsAdmin(!!data.isAdmin))
.catch(() => { });
}, []);
useEffect(() => { useEffect(() => {
loadFeed(feedType); loadFeed(feedType);
}, [feedType]); }, [feedType]);
@@ -449,158 +378,82 @@ export default function Home() {
}; };
return ( return (
<div className="layout"> <>
{/* Sidebar */} <header style={{
<aside className="sidebar"> padding: '16px',
<div className="logo"> borderBottom: '1px solid var(--border)',
<SynapsisLogo /> position: 'sticky',
<span>Synapsis</span> top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1>
<div className="feed-toggle">
<button
className={`feed-toggle-btn ${feedType === 'latest' ? 'active' : ''}`}
onClick={() => setFeedType('latest')}
>
Latest
</button>
<button
className={`feed-toggle-btn ${feedType === 'curated' ? 'active' : ''}`}
onClick={() => setFeedType('curated')}
>
Curated
</button>
</div>
</div> </div>
<nav> </header>
<Link href="/" className="nav-item active">
<HomeIcon />
<span>Home</span>
</Link>
<Link href="/explore" className="nav-item">
<SearchIcon />
<span>Explore</span>
</Link>
<Link href="/notifications" className="nav-item">
<BellIcon />
<span>Notifications</span>
</Link>
{user ? (
<Link href={`/${user.handle}`} className="nav-item">
<UserIcon />
<span>Profile</span>
</Link>
) : (
<Link href="/login" className="nav-item">
<UserIcon />
<span>Login</span>
</Link>
)}
{isAdmin && (
<Link href="/admin" className="nav-item">
<ShieldIcon />
<span>Admin</span>
</Link>
)}
</nav>
{user && (
<div style={{ marginTop: 'auto', paddingTop: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div className="avatar avatar-sm">
{user.displayName?.charAt(0) || user.handle.charAt(0)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{user.displayName}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>@{user.handle}</div>
</div>
</div>
</div>
)}
</aside>
{/* Main Feed */} {user && <Compose onPost={handlePost} />}
<main className="main">
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1>
<div className="feed-toggle">
<button
className={`feed-toggle-btn ${feedType === 'latest' ? 'active' : ''}`}
onClick={() => setFeedType('latest')}
>
Latest
</button>
<button
className={`feed-toggle-btn ${feedType === 'curated' ? 'active' : ''}`}
onClick={() => setFeedType('curated')}
>
Curated
</button>
</div>
</div>
</header>
{user && <Compose onPost={handlePost} />} {!user && (
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
{!user && ( <p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}> Join Synapsis to post and interact
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
Join Synapsis to post and interact
</p>
<Link href="/login" className="btn btn-primary">
Login or Register
</Link>
</div>
)}
{feedType === 'curated' && feedMeta && (
<div className="feed-meta card">
<div className="feed-meta-title">Curated feed</div>
<div className="feed-meta-body">
We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
</div>
<div className="feed-meta-weights">
Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}.
</div>
<div className="feed-meta-foot">
Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts.
</div>
</div>
)}
{loading ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
) : posts.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>No posts yet</p>
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
</div>
) : (
posts.map(post => (
<PostCard
key={post.id}
post={post}
onLike={handleLike}
onRepost={handleRepost}
/>
))
)}
</main>
{/* Right Sidebar */}
<aside className="aside">
<div className="card">
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Welcome to Synapsis</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
A federated social network designed as global communication infrastructure.
Signal over noise. Identity that&apos;s truly yours.
</p> </p>
<Link href="/login" className="btn btn-primary">
Login or Register
</Link>
</div> </div>
)}
<div className="card" style={{ marginTop: '16px' }}> {feedType === 'curated' && feedMeta && (
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Node Info</h3> <div className="feed-meta card">
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}> <div className="feed-meta-title">Curated feed</div>
{process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node'} <div className="feed-meta-body">
</p> We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '12px', marginTop: '4px' }}> </div>
Running Synapsis v0.1.0 <div className="feed-meta-weights">
</p> Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}.
</div>
<div className="feed-meta-foot">
Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts.
</div>
</div> </div>
</aside> )}
</div>
{loading ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
) : posts.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>No posts yet</p>
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
</div>
) : (
posts.map(post => (
<PostCard
key={post.id}
post={post}
onLike={handleLike}
onRepost={handleRepost}
/>
))
)}
</>
); );
} }
+103
View File
@@ -0,0 +1,103 @@
import React from 'react';
export const ShieldIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
export const HomeIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
export const SearchIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
export const BellIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
export const UserIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
export const HeartIcon = ({ filled }: { filled?: boolean }) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
export const RepeatIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
export const MessageIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
);
export const FlagIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 5v16" />
<path d="M5 5h11l-1 4 1 4H5" />
</svg>
);
export const TrendingIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
);
export const UsersIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
export const SynapsisLogo = () => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="var(--foreground)" />
<path d="M16 6C10.477 6 6 10.477 6 16C6 21.523 10.477 26 16 26C21.523 26 26 21.523 26 16C26 10.477 21.523 6 16 6ZM16 24C11.5817 24 8 20.4183 8 16C8 11.5817 11.5817 8 16 8C20.4183 8 24 11.5817 24 16C24 20.4183 20.4183 24 16 24Z" fill="var(--background)" />
<path d="M16 12C13.7909 12 12 13.7909 12 16C12 18.2091 13.7909 20 16 20C18.2091 20 20 18.2091 20 16C20 13.7909 18.2091 12 16 12Z" fill="var(--background)" />
</svg>
);
export const ArrowLeftIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
);
export const CalendarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { usePathname } from 'next/navigation';
import { Sidebar } from './Sidebar';
import { RightSidebar } from './RightSidebar';
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Paths that should NOT have the app layout
const isStandalone =
pathname === '/login' ||
pathname === '/register' ||
pathname?.startsWith('/install') ||
pathname?.startsWith('/admin');
if (isStandalone) {
return <>{children}</>;
}
return (
<div className="layout">
<Sidebar />
<main className="main">
{children}
</main>
<RightSidebar />
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState, useEffect } from 'react';
export function RightSidebar() {
const [nodeInfo, setNodeInfo] = useState({
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: 'A federated social network designed as global communication infrastructure. Signal over noise. Identity that is truly yours.',
longDescription: '',
rules: '',
bannerUrl: '',
});
useEffect(() => {
fetch('/api/node')
.then(res => res.json())
.then(data => {
if (data.name) {
setNodeInfo(prev => ({ ...prev, ...data }));
}
})
.catch(() => { });
}, []);
return (
<aside className="aside">
<div className="card" style={{ position: 'relative', overflow: 'hidden', padding: 0 }}>
{nodeInfo.bannerUrl && (
<>
<div style={{
position: 'absolute', inset: 0,
background: `url(${nodeInfo.bannerUrl}) center/cover no-repeat`,
opacity: 0.6
}} />
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(to bottom, transparent 0%, var(--background-secondary) 90%)'
}} />
</>
)}
<div style={{ position: 'relative', zIndex: 1, padding: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Welcome to {nodeInfo.name}</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
{nodeInfo.description}
</p>
{nodeInfo.longDescription && (
<div style={{ marginTop: '16px', fontSize: '13px', color: 'var(--foreground-secondary)', lineHeight: 1.5 }}>
{nodeInfo.longDescription.split('\n').map((line, i) => (
<p key={i} style={{ marginBottom: '8px' }}>{line}</p>
))}
</div>
)}
{nodeInfo.rules && (
<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: '8px', letterSpacing: '0.05em' }}>
Node Rules
</h4>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.5 }}>
{nodeInfo.rules.split('\n').map((rule, i) => (
<div key={i} style={{ marginBottom: '4px', display: 'flex', gap: '8px' }}>
<span></span>
<span>{rule}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="card" style={{ marginTop: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Network Info</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
Running Synapsis v0.1.0
</p>
</div>
</aside>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SynapsisLogo } from './Icons';
export function Sidebar() {
const { user, isAdmin } = useAuth();
const pathname = usePathname();
// Home is exact match
const isHome = pathname === '/';
return (
<aside className="sidebar">
<Link href="/" className="logo">
<SynapsisLogo />
<span>Synapsis</span>
</Link>
<nav>
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
<HomeIcon />
<span>Home</span>
</Link>
<Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`}>
<SearchIcon />
<span>Explore</span>
</Link>
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`}>
<BellIcon />
<span>Notifications</span>
</Link>
{user ? (
<Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}>
<UserIcon />
<span>Profile</span>
</Link>
) : (
<Link href="/login" className={`nav-item ${pathname === '/login' ? 'active' : ''}`}>
<UserIcon />
<span>Login</span>
</Link>
)}
{isAdmin && (
<Link href="/admin" className={`nav-item ${pathname?.startsWith('/admin') ? 'active' : ''}`}>
<ShieldIcon />
<span>Admin</span>
</Link>
)}
</nav>
{user && (
<div style={{ marginTop: 'auto', paddingTop: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div className="avatar avatar-sm">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{user.displayName}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>@{user.handle}</div>
</div>
</div>
</div>
)}
</aside>
);
}
+3
View File
@@ -10,6 +10,9 @@ export const nodes = pgTable('nodes', {
domain: text('domain').notNull().unique(), domain: text('domain').notNull().unique(),
name: text('name').notNull(), name: text('name').notNull(),
description: text('description'), description: text('description'),
longDescription: text('long_description'),
rules: text('rules'),
bannerUrl: text('banner_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(),
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
export interface User {
id: string;
handle: string;
displayName: string;
avatarUrl?: string;
}
interface AuthContextType {
user: User | null;
isAdmin: boolean;
loading: boolean;
checkAdmin: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
isAdmin: false,
loading: true,
checkAdmin: async () => { },
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
const checkAdmin = async () => {
try {
const res = await fetch('/api/admin/me');
const data = await res.json();
setIsAdmin(!!data.isAdmin);
} catch {
setIsAdmin(false);
}
};
useEffect(() => {
const loadAuth = async () => {
setLoading(true);
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.user);
if (data.user) {
await checkAdmin();
}
} else {
setUser(null);
}
} catch {
setUser(null);
} finally {
setLoading(false);
}
};
loadAuth();
}, []);
return (
<AuthContext.Provider value={{ user, isAdmin, loading, checkAdmin }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);