feat: implement authentication context, admin node settings, and core layout components
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
|
||||
Generated
+14
@@ -30,6 +30,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.4",
|
||||
@@ -3835,6 +3836,19 @@
|
||||
"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": {
|
||||
"version": "0.31.8",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.4",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeftIcon, CalendarIcon, HeartIcon, RepeatIcon, MessageIcon, FlagIcon } from '@/components/Icons';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -43,49 +44,7 @@ interface Post {
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
return (
|
||||
|
||||
+134
-1
@@ -49,12 +49,20 @@ const formatDate = (value: string) => {
|
||||
|
||||
export default function AdminPage() {
|
||||
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 [posts, setPosts] = useState<AdminPost[]>([]);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
|
||||
const [nodeSettings, setNodeSettings] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
longDescription: '',
|
||||
rules: '',
|
||||
bannerUrl: '',
|
||||
});
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!isAdmin) return;
|
||||
if (tab === 'reports') loadReports();
|
||||
if (tab === 'posts') loadPosts();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'settings') loadNodeSettings();
|
||||
}, [tab, isAdmin, reportStatus]);
|
||||
|
||||
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 needsReason = action === 'suspend' || action === 'silence';
|
||||
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')}>
|
||||
Users
|
||||
</button>
|
||||
<button className={`admin-tab ${tab === 'settings' ? 'active' : ''}`} onClick={() => setTab('settings')}>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'reports' && (
|
||||
@@ -383,6 +434,88 @@ export default function AdminPage() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -28,49 +29,7 @@ interface Post {
|
||||
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 }) {
|
||||
const [liked, setLiked] = useState(false);
|
||||
|
||||
+10
-1
@@ -30,6 +30,9 @@ 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 { LayoutWrapper } from '@/components/LayoutWrapper';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -37,7 +40,13 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-156
@@ -1,167 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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`;
|
||||
}
|
||||
};
|
||||
import { BellIcon } from '@/components/Icons';
|
||||
|
||||
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 (
|
||||
<div className="notifications-shell">
|
||||
<header className="notifications-header">
|
||||
<div>
|
||||
<h1>Notifications</h1>
|
||||
<p>{unreadCount} unread</p>
|
||||
</div>
|
||||
<div className="notifications-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setPolling(!polling)}>
|
||||
{polling ? 'Pause polling' : 'Resume polling'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={markAllRead} disabled={notifications.length === 0}>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
<div className="notifications-page">
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--background)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Notifications</h1>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="notifications-empty">Loading notifications...</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="notifications-empty">No notifications yet.</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 style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, background: 'var(--background-secondary)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<BellIcon />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+73
-220
@@ -2,12 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
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>
|
||||
);
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -45,33 +40,7 @@ interface Post {
|
||||
}
|
||||
|
||||
// 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 }) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
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 }) {
|
||||
const [liked, setLiked] = useState(false);
|
||||
@@ -372,8 +316,7 @@ function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) =>
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
loadFeed(feedType);
|
||||
}, [feedType]);
|
||||
@@ -449,158 +378,82 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
{/* Sidebar */}
|
||||
<aside className="sidebar">
|
||||
<div className="logo">
|
||||
<SynapsisLogo />
|
||||
<span>Synapsis</span>
|
||||
<>
|
||||
<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>
|
||||
<nav>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{/* Main Feed */}
|
||||
<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 && <Compose onPost={handlePost} />}
|
||||
|
||||
{!user && (
|
||||
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
||||
<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's truly yours.
|
||||
{!user && (
|
||||
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Node Info</h3>
|
||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
||||
{process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node'}
|
||||
</p>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '12px', marginTop: '4px' }}>
|
||||
Running Synapsis v0.1.0
|
||||
</p>
|
||||
{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>
|
||||
</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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export const nodes = pgTable('nodes', {
|
||||
domain: text('domain').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
longDescription: text('long_description'),
|
||||
rules: text('rules'),
|
||||
bannerUrl: text('banner_url'),
|
||||
accentColor: text('accent_color').default('#FFFFFF'),
|
||||
publicKey: text('public_key'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user