Add admin panel: backend middleware, handler, routes + Next.js frontend

This commit is contained in:
Patrick Britton 2026-02-06 09:15:57 -06:00
parent 0954c1e2a3
commit 96616bd81f
33 changed files with 6474 additions and 1 deletions

7
admin/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
.next/
out/
.env.local
.env*.local
*.tsbuildinfo
next-env.d.ts

136
admin/README.md Normal file
View file

@ -0,0 +1,136 @@
# Sojorn Admin Panel
Secure administration frontend for the Sojorn social network platform.
## Features
- **Dashboard** — Real-time platform stats, user/post growth charts, quick actions
- **User Management** — Search, view, suspend, ban, verify, change roles, reset strikes
- **Post Management** — Browse, search, flag, remove, restore, view details
- **AI Moderation Queue** — Review AI-flagged content (OpenAI + Google Vision), approve/dismiss/remove/ban
- **Appeal System** — Full appeal workflow: review violations, approve/reject appeals, restore content
- **Reports** — Community reports management with action/dismiss workflow
- **Algorithm Settings** — Configure feed ranking weights and AI moderation thresholds
- **Categories** — Create, edit, manage content categories
- **System Health** — Database status, connection pool monitoring, audit log
## Tech Stack
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: TailwindCSS
- **Charts**: Recharts
- **Icons**: Lucide React
- **Backend**: Go (Gin) REST API at `api.sojorn.net`
## Setup
```bash
# Install dependencies
npm install
# Configure API endpoint
cp .env.local.example .env.local
# Edit NEXT_PUBLIC_API_URL if needed
# Run development server
npm run dev
```
The admin panel runs on **port 3001** by default.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `NEXT_PUBLIC_API_URL` | `https://api.sojorn.net` | Backend API base URL |
## Authentication
The admin panel uses the same JWT authentication as the main app. Users must have `role = 'admin'` in the `profiles` table to access admin endpoints.
### Setting up an admin user
```sql
-- On the VPS, connect to sojorn database
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
```
## Backend API Routes
All admin endpoints are under `/api/v1/admin/` and require:
1. Valid JWT token (Bearer auth)
2. User profile with `role = 'admin'`
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/admin/dashboard` | Platform stats |
| GET | `/admin/growth` | User/post growth data |
| GET | `/admin/users` | List users (search, filter) |
| GET | `/admin/users/:id` | User detail |
| PATCH | `/admin/users/:id/status` | Change user status |
| PATCH | `/admin/users/:id/role` | Change user role |
| PATCH | `/admin/users/:id/verification` | Toggle verification |
| POST | `/admin/users/:id/reset-strikes` | Reset strikes |
| GET | `/admin/posts` | List posts |
| GET | `/admin/posts/:id` | Post detail |
| PATCH | `/admin/posts/:id/status` | Change post status |
| DELETE | `/admin/posts/:id` | Delete post |
| GET | `/admin/moderation` | Moderation queue |
| PATCH | `/admin/moderation/:id/review` | Review flagged content |
| GET | `/admin/appeals` | List appeals |
| PATCH | `/admin/appeals/:id/review` | Review appeal |
| GET | `/admin/reports` | List reports |
| PATCH | `/admin/reports/:id` | Update report status |
| GET | `/admin/algorithm` | Get algorithm config |
| PUT | `/admin/algorithm` | Update algorithm config |
| GET | `/admin/categories` | List categories |
| POST | `/admin/categories` | Create category |
| PATCH | `/admin/categories/:id` | Update category |
| GET | `/admin/health` | System health check |
| GET | `/admin/audit-log` | Audit log |
## Deployment
```bash
# Build for production
npm run build
# Start production server
npm start
```
For production, serve behind Nginx with SSL. Add a server block for `admin.sojorn.net`:
```nginx
server {
listen 443 ssl http2;
server_name admin.sojorn.net;
ssl_certificate /etc/letsencrypt/live/admin.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.sojorn.net/privkey.pem;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
## Moderation Flow
```
Content Created → AI Analysis (OpenAI/Google Vision)
Score > threshold → Auto-flag → Moderation Queue
Admin reviews → Approve / Dismiss / Remove Content / Ban User
If removed → User sees violation → Can file appeal
Admin reviews appeal → Approve (restore) / Reject (uphold)
```

217
admin/deploy_server.sh Normal file
View file

@ -0,0 +1,217 @@
#!/bin/bash
set -e
echo "=== Sojorn Admin Panel Server Deployment ==="
# 1. Run DB migration
echo "--- Running DB migration ---"
export PGPASSWORD='A24Zr7AEoch4eO0N'
psql -U postgres -h localhost -d sojorn <<'EOSQL'
-- Algorithm configuration table
CREATE TABLE IF NOT EXISTS public.algorithm_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed default algorithm config values
INSERT INTO public.algorithm_config (key, value, description) VALUES
('feed_recency_weight', '0.4', 'Weight for post recency in feed ranking'),
('feed_engagement_weight', '0.3', 'Weight for engagement metrics (likes, comments)'),
('feed_harmony_weight', '0.2', 'Weight for author harmony/trust score'),
('feed_diversity_weight', '0.1', 'Weight for content diversity in feed'),
('moderation_auto_flag_threshold', '0.7', 'AI score threshold for auto-flagging content'),
('moderation_auto_remove_threshold', '0.95', 'AI score threshold for automatic content removal'),
('moderation_greed_keyword_threshold', '0.7', 'Keyword-based spam/greed detection threshold'),
('feed_max_posts_per_author', '3', 'Max posts from same author in a single feed page'),
('feed_boost_mutual_follow', '1.5', 'Multiplier boost for posts from mutual follows'),
('feed_beacon_boost', '1.2', 'Multiplier boost for beacon posts in nearby feeds')
ON CONFLICT (key) DO NOTHING;
-- Audit log table
CREATE TABLE IF NOT EXISTS public.audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id UUID,
details TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON public.audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON public.audit_log(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON public.audit_log(action);
-- Ensure profiles.role column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'role'
) THEN
ALTER TABLE public.profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
END IF;
END $$;
-- Ensure profiles.is_verified column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_verified'
) THEN
ALTER TABLE public.profiles ADD COLUMN is_verified BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Ensure profiles.is_private column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_private'
) THEN
ALTER TABLE public.profiles ADD COLUMN is_private BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Ensure users.status column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'status'
) THEN
ALTER TABLE public.users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
END IF;
END $$;
-- Ensure users.last_login column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_login'
) THEN
ALTER TABLE public.users ADD COLUMN last_login TIMESTAMPTZ;
END IF;
END $$;
EOSQL
echo "--- DB migration complete ---"
# 2. Check/install Node.js
echo "--- Checking Node.js ---"
if ! command -v node &> /dev/null; then
echo "Installing Node.js 20..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -S bash -
echo 'P22k154ever!' | sudo -S apt-get install -y nodejs
fi
echo "Node: $(node --version), npm: $(npm --version)"
# 3. Pull latest code
echo "--- Pulling latest code ---"
cd /opt/sojorn
git pull origin main || echo "Git pull skipped (may not be configured)"
# 4. Build Go backend
echo "--- Building Go backend ---"
cd /opt/sojorn/go-backend
go build -ldflags="-s -w" -o /opt/sojorn/bin/api ./cmd/api/main.go
echo "Go backend built successfully"
# 5. Restart Go backend
echo "--- Restarting Go backend ---"
echo 'P22k154ever!' | sudo -S systemctl restart sojorn-api
sleep 3
echo 'P22k154ever!' | sudo -S systemctl status sojorn-api --no-pager || true
# 6. Setup admin frontend
echo "--- Setting up admin frontend ---"
mkdir -p /opt/sojorn/admin
cd /opt/sojorn/admin
# Check if package.json exists (code should be pulled via git)
if [ ! -f package.json ]; then
echo "Admin frontend source not found at /opt/sojorn/admin"
echo "Please ensure the admin/ directory is in the git repo and pulled"
exit 1
fi
npm install --production=false
npx next build
echo "--- Admin frontend built ---"
# 7. Create .env.local for admin
cat > /opt/sojorn/admin/.env.local <<'EOF'
NEXT_PUBLIC_API_URL=https://api.sojorn.net
EOF
# 8. Create systemd service for admin
echo 'P22k154ever!' | sudo -S tee /etc/systemd/system/sojorn-admin.service > /dev/null <<'EOF'
[Unit]
Description=Sojorn Admin Panel
After=network.target sojorn-api.service
[Service]
Type=simple
User=patrick
Group=patrick
WorkingDirectory=/opt/sojorn/admin
ExecStart=/usr/bin/npx next start --port 3001
Restart=always
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=/opt/sojorn/admin/.env.local
[Install]
WantedBy=multi-user.target
EOF
echo 'P22k154ever!' | sudo -S systemctl daemon-reload
echo 'P22k154ever!' | sudo -S systemctl enable sojorn-admin
echo 'P22k154ever!' | sudo -S systemctl restart sojorn-admin
sleep 3
echo 'P22k154ever!' | sudo -S systemctl status sojorn-admin --no-pager || true
# 9. Setup Nginx
echo "--- Setting up Nginx for admin ---"
echo 'P22k154ever!' | sudo -S tee /etc/nginx/sites-available/sojorn-admin > /dev/null <<'EOF'
server {
listen 80;
server_name admin.sojorn.net;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
EOF
# Enable site if not already
if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then
echo 'P22k154ever!' | sudo -S ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/
fi
echo 'P22k154ever!' | sudo -S nginx -t
echo 'P22k154ever!' | sudo -S systemctl reload nginx
echo "=== Deployment complete! ==="
echo "Admin panel running on port 3001"
echo "Nginx configured for admin.sojorn.net"
echo ""
echo "NEXT STEPS:"
echo "1. Point admin.sojorn.net DNS A record to this server IP"
echo "2. Run: sudo certbot --nginx -d admin.sojorn.net"
echo "3. Set an admin user: PGPASSWORD='A24Zr7AEoch4eO0N' psql -U postgres -h localhost -d sojorn -c \"UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';\""

12
admin/next.config.js Normal file
View file

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'img.sojorn.net' },
{ protocol: 'https', hostname: 'quips.sojorn.net' },
{ protocol: 'https', hostname: 'api.sojorn.net' },
],
},
};
module.exports = nextConfig;

2013
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
admin/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "sojorn-admin",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"lucide-react": "^0.400.0",
"recharts": "^2.12.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"class-variance-authority": "^0.7.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

6
admin/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,173 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import { Sliders, Save, RefreshCw } from 'lucide-react';
export default function AlgorithmPage() {
const [configs, setConfigs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const fetchConfig = () => {
setLoading(true);
api.getAlgorithmConfig()
.then((data) => {
setConfigs(data.config || []);
const vals: Record<string, string> = {};
(data.config || []).forEach((c: any) => { vals[c.key] = c.value; });
setEditValues(vals);
})
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchConfig(); }, []);
const handleSave = async (key: string) => {
setSaving(key);
try {
await api.updateAlgorithmConfig(key, editValues[key]);
fetchConfig();
} catch {}
setSaving(null);
};
const groupedConfigs = {
feed: configs.filter((c) => c.key.startsWith('feed_')),
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
other: configs.filter((c) => !c.key.startsWith('feed_') && !c.key.startsWith('moderation_')),
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Algorithm & Feed Settings</h1>
<p className="text-sm text-gray-500 mt-1">Configure feed ranking weights and moderation thresholds</p>
</div>
<button onClick={fetchConfig} className="btn-secondary text-sm flex items-center gap-1">
<RefreshCw className="w-4 h-4" /> Refresh
</button>
</div>
{loading ? (
<div className="card p-8 animate-pulse"><div className="h-40 bg-warm-300 rounded" /></div>
) : (
<div className="space-y-6">
{/* Feed Settings */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Sliders className="w-5 h-5 text-brand-500" />
<h3 className="text-lg font-semibold text-gray-900">Feed Ranking Weights</h3>
</div>
<p className="text-sm text-gray-500 mb-4">
These weights control how posts are ranked in users&apos; feeds. Values should be between 0 and 1 and ideally sum to 1.0.
</p>
<div className="space-y-4">
{groupedConfigs.feed.map((config) => (
<div key={config.key} className="flex items-center gap-4">
<div className="flex-1">
<label className="text-sm font-medium text-gray-700">{config.key.replace('feed_', '').replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())}</label>
<p className="text-xs text-gray-400">{config.description || config.key}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
className="input w-24 text-center text-sm"
value={editValues[config.key] || ''}
onChange={(e) => setEditValues({ ...editValues, [config.key]: e.target.value })}
/>
<button
onClick={() => handleSave(config.key)}
className="btn-primary text-xs py-2 px-3"
disabled={saving === config.key || editValues[config.key] === config.value}
>
{saving === config.key ? '...' : <Save className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
{groupedConfigs.feed.length === 0 && (
<p className="text-sm text-gray-400 italic">No feed settings configured yet. They will appear once the algorithm_config table is populated.</p>
)}
</div>
</div>
{/* Moderation Settings */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Sliders className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-semibold text-gray-900">AI Moderation Thresholds</h3>
</div>
<p className="text-sm text-gray-500 mb-4">
Control the sensitivity of the AI moderation system. Lower thresholds = more aggressive flagging.
</p>
<div className="space-y-4">
{groupedConfigs.moderation.map((config) => (
<div key={config.key} className="flex items-center gap-4">
<div className="flex-1">
<label className="text-sm font-medium text-gray-700">{config.key.replace('moderation_', '').replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())}</label>
<p className="text-xs text-gray-400">{config.description || config.key}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
className="input w-24 text-center text-sm"
value={editValues[config.key] || ''}
onChange={(e) => setEditValues({ ...editValues, [config.key]: e.target.value })}
/>
<button
onClick={() => handleSave(config.key)}
className="btn-primary text-xs py-2 px-3"
disabled={saving === config.key || editValues[config.key] === config.value}
>
{saving === config.key ? '...' : <Save className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
{groupedConfigs.moderation.length === 0 && (
<p className="text-sm text-gray-400 italic">No moderation thresholds configured yet.</p>
)}
</div>
</div>
{/* Other */}
{groupedConfigs.other.length > 0 && (
<div className="card p-5">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Other Settings</h3>
<div className="space-y-4">
{groupedConfigs.other.map((config) => (
<div key={config.key} className="flex items-center gap-4">
<div className="flex-1">
<label className="text-sm font-medium text-gray-700">{config.key}</label>
<p className="text-xs text-gray-400">{config.description}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
className="input w-24 text-center text-sm"
value={editValues[config.key] || ''}
onChange={(e) => setEditValues({ ...editValues, [config.key]: e.target.value })}
/>
<button
onClick={() => handleSave(config.key)}
className="btn-primary text-xs py-2 px-3"
disabled={saving === config.key || editValues[config.key] === config.value}
>
{saving === config.key ? '...' : <Save className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,187 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Scale, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
export default function AppealsPage() {
const [appeals, setAppeals] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('pending');
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewDecision, setReviewDecision] = useState('');
const [restoreContent, setRestoreContent] = useState(false);
const fetchAppeals = () => {
setLoading(true);
api.listAppeals({ limit: 50, status: statusFilter })
.then((data) => { setAppeals(data.appeals); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchAppeals(); }, [statusFilter]);
const handleReview = async (id: string, decision: 'approved' | 'rejected') => {
if (!reviewDecision.trim()) return;
try {
await api.reviewAppeal(id, decision, reviewDecision, restoreContent);
setReviewingId(null);
setReviewDecision('');
setRestoreContent(false);
fetchAppeals();
} catch {}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Appeals</h1>
<p className="text-sm text-gray-500 mt-1">{total} {statusFilter} appeals</p>
</div>
<select className="input w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-24 bg-warm-300 rounded" /></div>)}
</div>
) : appeals.length === 0 ? (
<div className="card p-12 text-center">
<Scale className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {statusFilter} appeals</p>
</div>
) : (
<div className="space-y-4">
{appeals.map((appeal) => (
<div key={appeal.id} className="card p-5">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className={`badge ${statusColor(appeal.status)}`}>{appeal.status}</span>
<span className="badge bg-orange-50 text-orange-700">{appeal.violation_type}</span>
<span className="text-xs text-gray-400">{formatDateTime(appeal.created_at)}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Violation Info */}
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Violation</h4>
<div className="bg-red-50 rounded-lg p-3 mb-3">
<p className="text-sm font-medium text-red-800">{appeal.violation_reason}</p>
{appeal.flag_reason && <p className="text-xs text-red-600 mt-1">Flag: {appeal.flag_reason}</p>}
<p className="text-xs text-red-500 mt-1">Severity: {(appeal.severity_score * 100).toFixed(0)}%</p>
</div>
{/* Original Content */}
{(appeal.post_body || appeal.comment_body) && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">Original Content</h4>
<div className="bg-warm-100 rounded-lg p-3">
<p className="text-sm text-gray-700 whitespace-pre-wrap">{appeal.post_body || appeal.comment_body}</p>
</div>
</div>
)}
</div>
{/* Appeal Info */}
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Appeal by @{appeal.user?.handle || '—'}</h4>
<div className="bg-blue-50 rounded-lg p-3 mb-3">
<p className="text-sm text-blue-900 font-medium mb-1">Reason:</p>
<p className="text-sm text-blue-800">{appeal.appeal_reason}</p>
{appeal.appeal_context && (
<>
<p className="text-sm text-blue-900 font-medium mt-2 mb-1">Context:</p>
<p className="text-sm text-blue-800">{appeal.appeal_context}</p>
</>
)}
</div>
{/* AI Scores */}
{appeal.flag_scores && (
<div className="text-xs text-gray-500 space-y-1">
{Object.entries(appeal.flag_scores).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<span className="w-16">{key}</span>
<div className="flex-1 h-1.5 bg-warm-300 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${(value as number) > 0.7 ? 'bg-red-500' : (value as number) > 0.4 ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${(value as number) * 100}%` }}
/>
</div>
<span className="w-8 text-right font-mono">{((value as number) * 100).toFixed(0)}%</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Review Actions */}
{appeal.status === 'pending' && (
<>
{reviewingId === appeal.id ? (
<div className="mt-4 p-4 bg-warm-100 border border-warm-400 rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-2">Write your review decision:</p>
<textarea
className="input mb-3"
rows={3}
placeholder="Explain your decision (min 5 chars)..."
value={reviewDecision}
onChange={(e) => setReviewDecision(e.target.value)}
/>
<label className="flex items-center gap-2 text-sm text-gray-600 mb-3">
<input type="checkbox" checked={restoreContent} onChange={(e) => setRestoreContent(e.target.checked)} className="rounded" />
Restore original content (if approving)
</label>
<div className="flex gap-2">
<button onClick={() => { setReviewingId(null); setReviewDecision(''); }} className="btn-secondary text-sm">Cancel</button>
<button
onClick={() => handleReview(appeal.id, 'approved')}
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 flex items-center gap-1"
disabled={reviewDecision.trim().length < 5}
>
<CheckCircle className="w-4 h-4" /> Approve Appeal
</button>
<button
onClick={() => handleReview(appeal.id, 'rejected')}
className="btn-danger text-sm flex items-center gap-1"
disabled={reviewDecision.trim().length < 5}
>
<XCircle className="w-4 h-4" /> Reject Appeal
</button>
</div>
</div>
) : (
<div className="mt-4 flex gap-2">
<button onClick={() => setReviewingId(appeal.id)} className="btn-primary text-sm flex items-center gap-1">
<Scale className="w-4 h-4" /> Review This Appeal
</button>
</div>
)}
</>
)}
{/* Already reviewed */}
{appeal.review_decision && (
<div className="mt-4 p-3 bg-warm-100 rounded-lg">
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">Review Decision</p>
<p className="text-sm text-gray-700">{appeal.review_decision}</p>
{appeal.reviewed_at && <p className="text-xs text-gray-400 mt-1">Reviewed {formatDateTime(appeal.reviewed_at)}</p>}
</div>
)}
</div>
))}
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,147 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { FolderTree, Plus, Save } from 'lucide-react';
export default function CategoriesPage() {
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newCat, setNewCat] = useState({ slug: '', name: '', description: '', is_sensitive: false });
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState({ name: '', description: '', is_sensitive: false });
const fetchCategories = () => {
setLoading(true);
api.listCategories()
.then((data) => setCategories(data.categories || []))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchCategories(); }, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await api.createCategory(newCat);
setShowCreate(false);
setNewCat({ slug: '', name: '', description: '', is_sensitive: false });
fetchCategories();
} catch {}
};
const handleUpdate = async (id: string) => {
try {
await api.updateCategory(id, editData);
setEditingId(null);
fetchCategories();
} catch {}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Categories</h1>
<p className="text-sm text-gray-500 mt-1">Manage content categories</p>
</div>
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary text-sm flex items-center gap-1">
<Plus className="w-4 h-4" /> New Category
</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="card p-5 mb-4 space-y-3">
<h3 className="font-semibold text-gray-900">Create Category</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input className="input" placeholder="Slug (e.g. tech)" value={newCat.slug} onChange={(e) => setNewCat({ ...newCat, slug: e.target.value })} required />
<input className="input" placeholder="Display Name" value={newCat.name} onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} required />
</div>
<input className="input" placeholder="Description (optional)" value={newCat.description} onChange={(e) => setNewCat({ ...newCat, description: e.target.value })} />
<label className="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" checked={newCat.is_sensitive} onChange={(e) => setNewCat({ ...newCat, is_sensitive: e.target.checked })} className="rounded" />
Sensitive content (opt-in only)
</label>
<div className="flex gap-2">
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary text-sm">Cancel</button>
<button type="submit" className="btn-primary text-sm">Create</button>
</div>
</form>
)}
{loading ? (
<div className="card p-8 animate-pulse"><div className="h-40 bg-warm-300 rounded" /></div>
) : categories.length === 0 ? (
<div className="card p-12 text-center">
<FolderTree className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">No categories yet</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">Slug</th>
<th className="table-header">Name</th>
<th className="table-header">Description</th>
<th className="table-header">Sensitive</th>
<th className="table-header">Created</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-warm-50">
<td className="table-cell font-mono text-xs">{cat.slug}</td>
<td className="table-cell">
{editingId === cat.id ? (
<input className="input text-sm py-1" value={editData.name} onChange={(e) => setEditData({ ...editData, name: e.target.value })} />
) : (
<span className="font-medium text-gray-900">{cat.name}</span>
)}
</td>
<td className="table-cell max-w-xs">
{editingId === cat.id ? (
<input className="input text-sm py-1" value={editData.description} onChange={(e) => setEditData({ ...editData, description: e.target.value })} />
) : (
<span className="text-sm text-gray-500">{cat.description || '—'}</span>
)}
</td>
<td className="table-cell">
{editingId === cat.id ? (
<input type="checkbox" checked={editData.is_sensitive} onChange={(e) => setEditData({ ...editData, is_sensitive: e.target.checked })} />
) : (
<span className={`badge ${cat.is_sensitive ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
{cat.is_sensitive ? 'Yes' : 'No'}
</span>
)}
</td>
<td className="table-cell text-xs text-gray-500">{formatDate(cat.created_at)}</td>
<td className="table-cell">
{editingId === cat.id ? (
<div className="flex gap-1">
<button onClick={() => handleUpdate(cat.id)} className="p-1.5 bg-green-50 text-green-700 rounded hover:bg-green-100"><Save className="w-4 h-4" /></button>
<button onClick={() => setEditingId(null)} className="text-xs text-gray-500 hover:text-gray-700 px-2">Cancel</button>
</div>
) : (
<button
onClick={() => { setEditingId(cat.id); setEditData({ name: cat.name, description: cat.description || '', is_sensitive: cat.is_sensitive }); }}
className="text-brand-500 hover:text-brand-700 text-xs font-medium"
>
Edit
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AdminShell>
);
}

36
admin/src/app/globals.css Normal file
View file

@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-warm-100 text-gray-900 antialiased;
}
}
@layer components {
.card {
@apply bg-white rounded-xl border border-warm-300 shadow-sm;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.btn-primary {
@apply bg-brand-500 text-white px-4 py-2 rounded-lg font-medium hover:bg-brand-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-warm-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-warm-300 transition-colors;
}
.btn-danger {
@apply bg-red-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-red-700 transition-colors;
}
.input {
@apply w-full px-3 py-2 border border-warm-400 rounded-lg bg-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition-colors;
}
.table-header {
@apply px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider;
}
.table-cell {
@apply px-4 py-3 text-sm text-gray-700;
}
}

18
admin/src/app/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import type { Metadata } from 'next';
import './globals.css';
import { AuthProvider } from '@/lib/auth';
export const metadata: Metadata = {
title: 'Sojorn Admin',
description: 'Sojorn Social Network Administration Panel',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
router.push('/');
} catch (err: any) {
setError(err.message || 'Login failed. Check your credentials.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-warm-100">
<div className="w-full max-w-md">
<div className="card p-8">
<div className="text-center mb-8">
<div className="w-14 h-14 bg-brand-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h1 className="text-2xl font-semibold text-gray-900">Sojorn Admin</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to manage the platform</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@sojorn.net"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-4">
Only authorized administrators can access this panel.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,172 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Shield, CheckCircle, XCircle, Trash2, Ban, AlertTriangle } from 'lucide-react';
function ScoreBar({ label, value }: { label: string; value: number }) {
const pct = Math.round(value * 100);
const color = pct > 70 ? 'bg-red-500' : pct > 40 ? 'bg-yellow-500' : 'bg-green-500';
return (
<div className="flex items-center gap-2 text-xs">
<span className="w-16 text-gray-500">{label}</span>
<div className="flex-1 h-2 bg-warm-300 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right font-mono text-gray-600">{pct}%</span>
</div>
);
}
export default function ModerationPage() {
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('pending');
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reason, setReason] = useState('');
const fetchQueue = () => {
setLoading(true);
api.getModerationQueue({ limit: 50, status: statusFilter })
.then((data) => { setItems(data.items); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchQueue(); }, [statusFilter]);
const handleReview = async (id: string, action: string) => {
try {
await api.reviewModerationFlag(id, action, reason || 'Admin review');
setReviewingId(null);
setReason('');
fetchQueue();
} catch {}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Moderation Queue</h1>
<p className="text-sm text-gray-500 mt-1">{total} items {statusFilter}</p>
</div>
<select className="input w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="pending">Pending</option>
<option value="actioned">Actioned</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
</div>
) : items.length === 0 ? (
<div className="card p-12 text-center">
<Shield className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {statusFilter} items in the queue</p>
<p className="text-sm text-gray-400 mt-1">All clear! The AI moderation system hasn&apos;t flagged any new content.</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="card p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className={`badge ${statusColor(item.status)}`}>{item.status}</span>
<span className="badge bg-gray-100 text-gray-600">{item.content_type}</span>
<span className="badge bg-red-50 text-red-700">
<AlertTriangle className="w-3 h-3 mr-1" />
{item.flag_reason}
</span>
<span className="text-xs text-gray-400">{formatDateTime(item.created_at)}</span>
</div>
{/* Content */}
<div className="bg-warm-100 rounded-lg p-3 mb-3">
{item.content_type === 'post' ? (
<div>
<p className="text-sm text-gray-800 whitespace-pre-wrap">{item.post_body || 'No text content'}</p>
{item.post_image && (
<div className="mt-2 text-xs text-gray-400">📷 Has image: {item.post_image}</div>
)}
{item.post_video && (
<div className="mt-1 text-xs text-gray-400">🎬 Has video: {item.post_video}</div>
)}
</div>
) : (
<p className="text-sm text-gray-800">{item.comment_body || 'No content'}</p>
)}
</div>
{/* Author */}
<p className="text-xs text-gray-500 mb-3">
By <span className="font-medium text-gray-700">@{item.author_handle || '—'}</span>
{item.author_name && ` (${item.author_name})`}
</p>
{/* AI Scores */}
{item.scores && (
<div className="space-y-1 max-w-xs">
{item.scores.hate != null && <ScoreBar label="Hate" value={item.scores.hate} />}
{item.scores.greed != null && <ScoreBar label="Greed" value={item.scores.greed} />}
{item.scores.delusion != null && <ScoreBar label="Delusion" value={item.scores.delusion} />}
</div>
)}
</div>
{/* Actions */}
{item.status === 'pending' && (
<div className="flex flex-col gap-2 flex-shrink-0">
<button
onClick={() => handleReview(item.id, 'approve')}
className="flex items-center gap-1.5 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-xs font-medium hover:bg-green-100"
>
<CheckCircle className="w-4 h-4" /> Approve
</button>
<button
onClick={() => handleReview(item.id, 'dismiss')}
className="flex items-center gap-1.5 px-3 py-2 bg-gray-50 text-gray-600 rounded-lg text-xs font-medium hover:bg-gray-100"
>
<XCircle className="w-4 h-4" /> Dismiss
</button>
<button
onClick={() => handleReview(item.id, 'remove_content')}
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-xs font-medium hover:bg-red-100"
>
<Trash2 className="w-4 h-4" /> Remove
</button>
<button
onClick={() => setReviewingId(item.id)}
className="flex items-center gap-1.5 px-3 py-2 bg-red-100 text-red-800 rounded-lg text-xs font-medium hover:bg-red-200"
>
<Ban className="w-4 h-4" /> Ban User
</button>
</div>
)}
</div>
{/* Ban modal inline */}
{reviewingId === item.id && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm font-medium text-red-800 mb-2">Ban user and remove content</p>
<input className="input mb-2" placeholder="Reason for ban..." value={reason} onChange={(e) => setReason(e.target.value)} />
<div className="flex gap-2">
<button onClick={() => { setReviewingId(null); setReason(''); }} className="btn-secondary text-xs">Cancel</button>
<button onClick={() => handleReview(item.id, 'ban_user')} className="btn-danger text-xs" disabled={!reason.trim()}>Confirm Ban</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</AdminShell>
);
}

125
admin/src/app/page.tsx Normal file
View file

@ -0,0 +1,125 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import { Users, FileText, Shield, Scale, Flag, TrendingUp, TrendingDown, UserPlus } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from 'recharts';
function StatCard({ label, value, icon: Icon, sub, color }: { label: string; value: number | string; icon: any; sub?: string; color: string }) {
return (
<div className="card p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{label}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
</div>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${color}`}>
<Icon className="w-6 h-6" />
</div>
</div>
</div>
);
}
export default function DashboardPage() {
const [stats, setStats] = useState<any>(null);
const [growth, setGrowth] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.getDashboardStats(), api.getGrowthStats(30)])
.then(([s, g]) => { setStats(s); setGrowth(g); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<AdminShell>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Overview of Sojorn platform activity</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="card p-5 animate-pulse">
<div className="h-4 bg-warm-300 rounded w-24 mb-3" />
<div className="h-8 bg-warm-300 rounded w-16" />
</div>
))}
</div>
) : stats ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard label="Total Users" value={stats.users?.total || 0} icon={Users} sub={`${stats.users?.new_today || 0} new today`} color="bg-blue-100 text-blue-600" />
<StatCard label="Active Users" value={stats.users?.active || 0} icon={UserPlus} color="bg-green-100 text-green-600" />
<StatCard label="Total Posts" value={stats.posts?.total || 0} icon={FileText} sub={`${stats.posts?.new_today || 0} new today`} color="bg-purple-100 text-purple-600" />
<StatCard label="Flagged Posts" value={stats.posts?.flagged || 0} icon={Shield} color="bg-red-100 text-red-600" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard label="Pending Flags" value={stats.moderation?.pending_flags || 0} icon={Shield} color="bg-yellow-100 text-yellow-600" />
<StatCard label="Pending Appeals" value={stats.appeals?.pending || 0} icon={Scale} color="bg-orange-100 text-orange-600" />
<StatCard label="Pending Reports" value={stats.reports?.pending || 0} icon={Flag} color="bg-pink-100 text-pink-600" />
<StatCard label="Banned Users" value={stats.users?.banned || 0} icon={TrendingDown} color="bg-red-100 text-red-600" />
</div>
{/* Charts */}
{growth && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-4">User Growth (30 days)</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={growth.user_growth || []}>
<CartesianGrid strokeDasharray="3 3" stroke="#E8E6E1" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} tickFormatter={(v) => v.slice(5)} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="#6B5B95" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Post Activity (30 days)</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={growth.post_growth || []}>
<CartesianGrid strokeDasharray="3 3" stroke="#E8E6E1" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} tickFormatter={(v) => v.slice(5)} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="count" fill="#6B5B95" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Quick Actions */}
<div className="mt-6 card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Quick Actions</h3>
<div className="flex flex-wrap gap-3">
<a href="/moderation" className="btn-primary text-sm flex items-center gap-2">
<Shield className="w-4 h-4" /> Review Moderation Queue ({stats.moderation?.pending_flags || 0})
</a>
<a href="/appeals" className="btn-secondary text-sm flex items-center gap-2">
<Scale className="w-4 h-4" /> Review Appeals ({stats.appeals?.pending || 0})
</a>
<a href="/reports" className="btn-secondary text-sm flex items-center gap-2">
<Flag className="w-4 h-4" /> Review Reports ({stats.reports?.pending || 0})
</a>
</div>
</div>
</>
) : (
<div className="card p-8 text-center text-gray-500">Failed to load dashboard data.</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,180 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Image, Video, MapPin, Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
export default function PostDetailPage() {
const params = useParams();
const router = useRouter();
const [post, setPost] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchPost = () => {
setLoading(true);
api.getPost(params.id as string)
.then(setPost)
.catch(() => router.push('/posts'))
.finally(() => setLoading(false));
};
useEffect(() => { fetchPost(); }, [params.id]);
const handleStatusChange = async (status: string) => {
try {
await api.updatePostStatus(params.id as string, status, 'Admin action');
fetchPost();
} catch {}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this post?')) return;
try {
await api.deletePost(params.id as string);
router.push('/posts');
} catch {}
};
return (
<AdminShell>
<Link href="/posts" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4" /> Back to Posts
</Link>
{loading ? (
<div className="card p-8 animate-pulse"><div className="h-40 bg-warm-300 rounded" /></div>
) : post ? (
<div className="space-y-6">
{/* Header */}
<div className="card p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-brand-100 rounded-full flex items-center justify-center text-brand-600 text-sm font-bold">
{(post.author?.handle || '?')[0].toUpperCase()}
</div>
<div>
<Link href={`/users/${post.author_id}`} className="font-medium text-gray-900 hover:text-brand-600">
{post.author?.display_name || post.author?.handle || '—'}
</Link>
<p className="text-xs text-gray-400">@{post.author?.handle || '—'} · {formatDateTime(post.created_at)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`badge ${statusColor(post.status)}`}>{post.status}</span>
{post.is_beacon && <span className="badge bg-orange-100 text-orange-700"><MapPin className="w-3 h-3 mr-1" /> Beacon</span>}
{post.visibility !== 'public' && <span className="badge bg-gray-100 text-gray-600">{post.visibility}</span>}
</div>
</div>
{/* Content */}
<div className="bg-warm-100 rounded-lg p-4 mb-4">
<p className="text-gray-800 whitespace-pre-wrap">{post.body || 'No text content'}</p>
</div>
{/* Media */}
<div className="flex flex-wrap gap-3 mb-4">
{post.image_url && (
<div className="flex items-center gap-2 text-sm text-gray-500 bg-warm-200 rounded-lg px-3 py-2">
<Image className="w-4 h-4" />
<a href={post.image_url} target="_blank" rel="noopener noreferrer" className="text-brand-500 hover:text-brand-700 truncate max-w-xs">
{post.image_url}
</a>
</div>
)}
{post.video_url && (
<div className="flex items-center gap-2 text-sm text-gray-500 bg-warm-200 rounded-lg px-3 py-2">
<Video className="w-4 h-4" />
<a href={post.video_url} target="_blank" rel="noopener noreferrer" className="text-brand-500 hover:text-brand-700 truncate max-w-xs">
{post.video_url}
</a>
</div>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{[
{ label: 'Likes', value: post.like_count },
{ label: 'Comments', value: post.comment_count },
{ label: 'Duration', value: post.duration_ms ? `${(post.duration_ms / 1000).toFixed(1)}s` : '—' },
{ label: 'Format', value: post.body_format || 'plain' },
].map((s) => (
<div key={s.label} className="bg-warm-100 rounded-lg p-3 text-center">
<p className="text-lg font-bold text-gray-900">{s.value ?? 0}</p>
<p className="text-xs text-gray-500">{s.label}</p>
</div>
))}
</div>
{/* Metadata */}
<div className="text-xs text-gray-400 space-y-1">
{post.tone_label && <p>Tone: {post.tone_label} {post.cis_score != null ? `(CIS: ${(post.cis_score * 100).toFixed(0)}%)` : ''}</p>}
{post.edited_at && <p>Edited: {formatDateTime(post.edited_at)}</p>}
{post.beacon_type && <p>Beacon type: {post.beacon_type}</p>}
<p>Allow chain: {post.allow_chain ? 'Yes' : 'No'}</p>
<p>Post ID: {post.id}</p>
</div>
</div>
{/* Moderation Flags */}
{post.moderation_flags && post.moderation_flags.length > 0 && (
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-orange-500" /> Moderation Flags ({post.moderation_flags.length})
</h3>
<div className="space-y-3">
{post.moderation_flags.map((flag: any) => (
<div key={flag.id} className="bg-warm-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className={`badge ${statusColor(flag.status)}`}>{flag.status}</span>
<span className="badge bg-red-50 text-red-700">{flag.flag_reason}</span>
<span className="text-xs text-gray-400">{formatDateTime(flag.created_at)}</span>
</div>
{flag.scores && (
<div className="flex gap-4 text-xs text-gray-500 mt-1">
{Object.entries(flag.scores).map(([key, value]) => (
<span key={key}>{key}: {((value as number) * 100).toFixed(0)}%</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Admin Actions</h3>
<div className="flex flex-wrap gap-2">
{post.status !== 'active' && (
<button onClick={() => handleStatusChange('active')} className="btn-primary text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> Restore / Activate
</button>
)}
{post.status === 'active' && (
<button onClick={() => handleStatusChange('flagged')} className="bg-yellow-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-yellow-600 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Flag
</button>
)}
{post.status !== 'removed' && (
<button onClick={() => handleStatusChange('removed')} className="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-600 flex items-center gap-1">
<Trash2 className="w-4 h-4" /> Remove
</button>
)}
<button onClick={handleDelete} className="btn-danger text-sm flex items-center gap-1">
<Trash2 className="w-4 h-4" /> Permanently Delete
</button>
</div>
</div>
</div>
) : (
<div className="card p-8 text-center text-gray-500">Post not found</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,145 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDate, truncate } from '@/lib/utils';
import { Suspense, useEffect, useState } from 'react';
import { Search, ChevronLeft, ChevronRight, Image, Video, MapPin } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
export default function PostsPage() {
return (
<Suspense fallback={<AdminShell><div className="card p-8 animate-pulse"><div className="h-40 bg-warm-300 rounded" /></div></AdminShell>}>
<PostsPageInner />
</Suspense>
);
}
function PostsPageInner() {
const searchParams = useSearchParams();
const [posts, setPosts] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [offset, setOffset] = useState(0);
const limit = 25;
const authorId = searchParams.get('author_id') || undefined;
const fetchPosts = () => {
setLoading(true);
api.listPosts({ limit, offset, search: search || undefined, status: statusFilter || undefined, author_id: authorId })
.then((data) => { setPosts(data.posts); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchPosts(); }, [offset, statusFilter]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
fetchPosts();
};
const handleQuickAction = async (postId: string, action: 'remove' | 'activate') => {
try {
await api.updatePostStatus(postId, action === 'remove' ? 'removed' : 'active', 'Admin action');
fetchPosts();
} catch {}
};
return (
<AdminShell>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Posts</h1>
<p className="text-sm text-gray-500 mt-1">{total} total posts{authorId ? ' (filtered by author)' : ''}</p>
</div>
{/* Filters */}
<div className="card p-4 mb-4 flex flex-wrap gap-3 items-center">
<form onSubmit={handleSearch} className="flex-1 min-w-[200px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input className="input pl-10" placeholder="Search post content..." value={search} onChange={(e) => setSearch(e.target.value)} />
</form>
<select className="input w-auto" value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setOffset(0); }}>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="flagged">Flagged</option>
<option value="removed">Removed</option>
</select>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">Content</th>
<th className="table-header">Author</th>
<th className="table-header">Media</th>
<th className="table-header">Status</th>
<th className="table-header">Engagement</th>
<th className="table-header">Created</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{loading ? (
[...Array(5)].map((_, i) => (
<tr key={i}>{[...Array(7)].map((_, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
))
) : posts.length === 0 ? (
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No posts found</td></tr>
) : (
posts.map((post) => (
<tr key={post.id} className="hover:bg-warm-50 transition-colors">
<td className="table-cell max-w-xs">
<p className="text-sm text-gray-900 line-clamp-2">{truncate(post.body || '', 80)}</p>
</td>
<td className="table-cell">
<Link href={`/users/${post.author_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
@{post.author?.handle || '—'}
</Link>
</td>
<td className="table-cell">
<div className="flex gap-1">
{post.image_url && <Image className="w-4 h-4 text-gray-400" />}
{post.video_url && <Video className="w-4 h-4 text-gray-400" />}
{post.is_beacon && <MapPin className="w-4 h-4 text-orange-400" />}
</div>
</td>
<td className="table-cell"><span className={`badge ${statusColor(post.status)}`}>{post.status}</span></td>
<td className="table-cell text-xs text-gray-500">
{post.like_count} likes · {post.comment_count} comments
</td>
<td className="table-cell text-gray-500 text-xs">{formatDate(post.created_at)}</td>
<td className="table-cell">
<div className="flex gap-2">
<Link href={`/posts/${post.id}`} className="text-brand-500 hover:text-brand-700 text-xs font-medium">View</Link>
{post.status === 'active' ? (
<button onClick={() => handleQuickAction(post.id, 'remove')} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
) : post.status === 'removed' ? (
<button onClick={() => handleQuickAction(post.id, 'activate')} className="text-green-500 hover:text-green-700 text-xs font-medium">Restore</button>
) : null}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="border-t border-warm-300 px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-500">Showing {offset + 1}{Math.min(offset + limit, total)} of {total}</p>
<div className="flex gap-2">
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}><ChevronLeft className="w-4 h-4" /></button>
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}><ChevronRight className="w-4 h-4" /></button>
</div>
</div>
</div>
</AdminShell>
);
}

View file

@ -0,0 +1,128 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Flag, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
export default function ReportsPage() {
const [reports, setReports] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('pending');
const fetchReports = () => {
setLoading(true);
api.listReports({ limit: 50, status: statusFilter })
.then((data) => { setReports(data.reports); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchReports(); }, [statusFilter]);
const handleUpdate = async (id: string, status: string) => {
try {
await api.updateReportStatus(id, status);
fetchReports();
} catch {}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<p className="text-sm text-gray-500 mt-1">{total} {statusFilter} reports</p>
</div>
<select className="input w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="pending">Pending</option>
<option value="reviewed">Reviewed</option>
<option value="actioned">Actioned</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-16 bg-warm-300 rounded" /></div>)}
</div>
) : reports.length === 0 ? (
<div className="card p-12 text-center">
<Flag className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {statusFilter} reports</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">Reporter</th>
<th className="table-header">Target</th>
<th className="table-header">Type</th>
<th className="table-header">Description</th>
<th className="table-header">Content</th>
<th className="table-header">Status</th>
<th className="table-header">Date</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{reports.map((report) => (
<tr key={report.id} className="hover:bg-warm-50 transition-colors">
<td className="table-cell">
<Link href={`/users/${report.reporter_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
@{report.reporter_handle || '—'}
</Link>
</td>
<td className="table-cell">
<Link href={`/users/${report.target_user_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
@{report.target_handle || '—'}
</Link>
</td>
<td className="table-cell">
<span className="badge bg-orange-50 text-orange-700">
<AlertTriangle className="w-3 h-3 mr-1" />{report.violation_type}
</span>
</td>
<td className="table-cell max-w-xs">
<p className="text-sm text-gray-700 line-clamp-2">{report.description}</p>
</td>
<td className="table-cell text-xs text-gray-500">
{report.post_id && <Link href={`/posts/${report.post_id}`} className="text-brand-500 hover:text-brand-700">View Post</Link>}
</td>
<td className="table-cell">
<span className={`badge ${statusColor(report.status)}`}>{report.status}</span>
</td>
<td className="table-cell text-xs text-gray-500">{formatDateTime(report.created_at)}</td>
<td className="table-cell">
{report.status === 'pending' && (
<div className="flex gap-1">
<button
onClick={() => handleUpdate(report.id, 'actioned')}
className="p-1.5 bg-green-50 text-green-700 rounded hover:bg-green-100"
title="Action taken"
>
<CheckCircle className="w-4 h-4" />
</button>
<button
onClick={() => handleUpdate(report.id, 'dismissed')}
className="p-1.5 bg-gray-50 text-gray-600 rounded hover:bg-gray-100"
title="Dismiss"
>
<XCircle className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,98 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { useState } from 'react';
import { Settings, Globe, Key } from 'lucide-react';
export default function SettingsPage() {
const [apiUrl, setApiUrl] = useState(
typeof window !== 'undefined' ? localStorage.getItem('admin_api_url') || '' : ''
);
const [saved, setSaved] = useState(false);
const handleSaveApiUrl = () => {
if (typeof window !== 'undefined') {
if (apiUrl.trim()) {
localStorage.setItem('admin_api_url', apiUrl.trim());
} else {
localStorage.removeItem('admin_api_url');
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
};
return (
<AdminShell>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="text-sm text-gray-500 mt-1">Admin panel configuration</p>
</div>
<div className="space-y-6 max-w-2xl">
{/* API Configuration */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Globe className="w-5 h-5 text-brand-500" />
<h3 className="text-lg font-semibold text-gray-900">API Connection</h3>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Base URL</label>
<input
className="input"
placeholder="https://api.sojorn.net (default)"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
/>
<p className="text-xs text-gray-400 mt-1">
Override the default API URL. Leave blank to use the default from environment variables.
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleSaveApiUrl} className="btn-primary text-sm">
Save
</button>
{saved && <span className="text-sm text-green-600">Saved!</span>}
</div>
</div>
</div>
{/* Session Info */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Key className="w-5 h-5 text-brand-500" />
<h3 className="text-lg font-semibold text-gray-900">Session</h3>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Token stored</span>
<span className="text-gray-900 font-medium">
{typeof window !== 'undefined' && localStorage.getItem('admin_token') ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">API URL</span>
<span className="text-gray-900 font-medium font-mono text-xs">
{process.env.NEXT_PUBLIC_API_URL || 'https://api.sojorn.net'}
</span>
</div>
</div>
</div>
{/* About */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Settings className="w-5 h-5 text-brand-500" />
<h3 className="text-lg font-semibold text-gray-900">About</h3>
</div>
<div className="space-y-2 text-sm text-gray-500">
<p><strong className="text-gray-700">Sojorn Admin Panel</strong> v1.0.0</p>
<p>Built with Next.js, React, TypeScript, and TailwindCSS</p>
<p>Backend: Go (Gin) + PostgreSQL</p>
</div>
</div>
</div>
</AdminShell>
);
}

View file

@ -0,0 +1,149 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Activity, Database, RefreshCw, Server, Clock } from 'lucide-react';
export default function SystemPage() {
const [health, setHealth] = useState<any>(null);
const [auditLog, setAuditLog] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = () => {
setLoading(true);
Promise.all([api.getSystemHealth(), api.getAuditLog({ limit: 20 })])
.then(([h, a]) => { setHealth(h); setAuditLog(a.entries || []); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchData(); }, []);
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">System Health</h1>
<p className="text-sm text-gray-500 mt-1">Monitor infrastructure and review audit log</p>
</div>
<button onClick={fetchData} className="btn-secondary text-sm flex items-center gap-1">
<RefreshCw className="w-4 h-4" /> Refresh
</button>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
</div>
) : health ? (
<div className="space-y-6">
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Overall */}
<div className="card p-5">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${health.status === 'healthy' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
<Server className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">API Server</p>
<p className={`text-lg font-bold ${health.status === 'healthy' ? 'text-green-600' : 'text-red-600'}`}>
{health.status === 'healthy' ? 'Healthy' : 'Unhealthy'}
</p>
</div>
</div>
</div>
{/* Database */}
<div className="card p-5">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${health.database?.status === 'healthy' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
<Database className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Database</p>
<p className={`text-lg font-bold ${health.database?.status === 'healthy' ? 'text-green-600' : 'text-red-600'}`}>
{health.database?.status === 'healthy' ? 'Connected' : 'Disconnected'}
</p>
</div>
</div>
{health.database?.latency_ms != null && (
<p className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" /> Latency: {health.database.latency_ms}ms</p>
)}
{health.database_size && (
<p className="text-xs text-gray-400 mt-1">Size: {health.database_size}</p>
)}
</div>
{/* Connection Pool */}
<div className="card p-5">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-blue-100 text-blue-600">
<Activity className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Connection Pool</p>
<p className="text-lg font-bold text-gray-900">
{health.connection_pool?.acquired || 0}/{health.connection_pool?.max || 0}
</p>
</div>
</div>
{health.connection_pool && (
<div className="space-y-1 text-xs text-gray-400">
<p>Total: {health.connection_pool.total} · Idle: {health.connection_pool.idle} · Acquired: {health.connection_pool.acquired}</p>
<div className="h-2 bg-warm-300 rounded-full overflow-hidden mt-1">
<div
className="h-full bg-brand-500 rounded-full"
style={{ width: `${health.connection_pool.max ? (health.connection_pool.acquired / health.connection_pool.max) * 100 : 0}%` }}
/>
</div>
</div>
)}
</div>
</div>
{/* Audit Log */}
<div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-warm-300">
<h3 className="text-lg font-semibold text-gray-900">Recent Audit Log</h3>
</div>
{auditLog.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm">No audit log entries yet</div>
) : (
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">Time</th>
<th className="table-header">Actor</th>
<th className="table-header">Action</th>
<th className="table-header">Target</th>
<th className="table-header">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{auditLog.map((entry) => (
<tr key={entry.id} className="hover:bg-warm-50">
<td className="table-cell text-xs text-gray-500">{formatDateTime(entry.created_at)}</td>
<td className="table-cell text-sm">@{entry.actor_handle || '—'}</td>
<td className="table-cell">
<span className="badge bg-blue-50 text-blue-700">{entry.action}</span>
</td>
<td className="table-cell text-xs text-gray-500">
{entry.target_type} {entry.target_id ? `(${String(entry.target_id).slice(0, 8)}...)` : ''}
</td>
<td className="table-cell text-xs text-gray-400 max-w-xs truncate">{entry.details || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
) : (
<div className="card p-8 text-center text-gray-500">Failed to load system health data.</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,259 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw } from 'lucide-react';
import Link from 'next/link';
export default function UserDetailPage() {
const params = useParams();
const router = useRouter();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showModal, setShowModal] = useState<string | null>(null);
const [reason, setReason] = useState('');
const fetchUser = () => {
setLoading(true);
api.getUser(params.id as string)
.then(setUser)
.catch(() => router.push('/users'))
.finally(() => setLoading(false));
};
useEffect(() => { fetchUser(); }, [params.id]);
const handleStatusChange = async (status: string) => {
if (!reason.trim()) return;
setActionLoading(true);
try {
await api.updateUserStatus(params.id as string, status, reason);
setShowModal(null);
setReason('');
fetchUser();
} catch {}
setActionLoading(false);
};
const handleRoleChange = async (role: string) => {
setActionLoading(true);
try {
await api.updateUserRole(params.id as string, role);
fetchUser();
} catch {}
setActionLoading(false);
};
const handleVerification = async (isOfficial: boolean, isVerified: boolean) => {
setActionLoading(true);
try {
await api.updateUserVerification(params.id as string, isOfficial, isVerified);
fetchUser();
} catch {}
setActionLoading(false);
};
const handleResetStrikes = async () => {
setActionLoading(true);
try {
await api.resetUserStrikes(params.id as string);
fetchUser();
} catch {}
setActionLoading(false);
};
return (
<AdminShell>
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4" /> Back to Users
</Link>
{loading ? (
<div className="card p-8 animate-pulse"><div className="h-6 bg-warm-300 rounded w-40" /></div>
) : user ? (
<div className="space-y-6">
{/* Header */}
<div className="card p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-brand-100 rounded-2xl flex items-center justify-center text-brand-600 text-xl font-bold">
{(user.handle || user.email || '?')[0].toUpperCase()}
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">{user.display_name || user.handle || '—'}</h1>
<p className="text-sm text-gray-500">@{user.handle || '—'} · {user.email}</p>
<div className="flex items-center gap-2 mt-2">
<span className={`badge ${statusColor(user.status)}`}>{user.status}</span>
<span className={`badge ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : user.role === 'moderator' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role || 'user'}
</span>
{user.is_official && <span className="badge bg-blue-100 text-blue-700">Official</span>}
{user.is_verified && <span className="badge bg-green-100 text-green-700">Verified</span>}
{user.is_private && <span className="badge bg-gray-100 text-gray-600">Private</span>}
</div>
</div>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{[
{ label: 'Followers', value: user.follower_count },
{ label: 'Following', value: user.following_count },
{ label: 'Posts', value: user.post_count },
{ label: 'Strikes', value: user.strikes },
{ label: 'Violations', value: user.violation_count },
{ label: 'Reports', value: user.report_count },
].map((s) => (
<div key={s.label} className="card p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{s.value ?? 0}</p>
<p className="text-xs text-gray-500 mt-1">{s.label}</p>
</div>
))}
</div>
{/* Details */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Profile Details</h3>
<dl className="space-y-2 text-sm">
{[
['Bio', user.bio],
['Location', user.location],
['Website', user.website],
['Country', user.origin_country],
['Beacon Enabled', user.beacon_enabled ? 'Yes' : 'No'],
['Onboarding Complete', user.has_completed_onboarding ? 'Yes' : 'No'],
['Joined', user.created_at ? formatDateTime(user.created_at) : '—'],
['Last Login', user.last_login ? formatDateTime(user.last_login) : 'Never'],
].map(([label, value]) => (
<div key={label as string} className="flex justify-between">
<dt className="text-gray-500">{label}</dt>
<dd className="text-gray-900 font-medium text-right max-w-xs truncate">{value || '—'}</dd>
</div>
))}
</dl>
</div>
{/* Actions */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Admin Actions</h3>
<div className="space-y-3">
{/* Status changes */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Account Status</p>
<div className="flex flex-wrap gap-2">
{user.status !== 'active' && (
<button onClick={() => setShowModal('active')} className="btn-primary text-xs py-1.5 flex items-center gap-1">
<CheckCircle className="w-3.5 h-3.5" /> Activate
</button>
)}
{user.status !== 'suspended' && (
<button onClick={() => setShowModal('suspended')} className="bg-orange-500 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-orange-600 flex items-center gap-1">
<XCircle className="w-3.5 h-3.5" /> Suspend
</button>
)}
{user.status !== 'banned' && (
<button onClick={() => setShowModal('banned')} className="btn-danger text-xs py-1.5 flex items-center gap-1">
<Ban className="w-3.5 h-3.5" /> Ban
</button>
)}
</div>
</div>
{/* Role */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Role</p>
<select
className="input text-sm"
value={user.role || 'user'}
onChange={(e) => handleRoleChange(e.target.value)}
disabled={actionLoading}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
</div>
{/* Verification */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Verification</p>
<div className="flex gap-2">
<button
onClick={() => handleVerification(!user.is_official, user.is_verified ?? false)}
className={`text-xs py-1.5 px-3 rounded-lg font-medium flex items-center gap-1 ${user.is_official ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
disabled={actionLoading}
>
<Star className="w-3.5 h-3.5" /> {user.is_official ? 'Remove Official' : 'Make Official'}
</button>
<button
onClick={() => handleVerification(user.is_official ?? false, !user.is_verified)}
className={`text-xs py-1.5 px-3 rounded-lg font-medium flex items-center gap-1 ${user.is_verified ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
disabled={actionLoading}
>
<CheckCircle className="w-3.5 h-3.5" /> {user.is_verified ? 'Unverify' : 'Verify'}
</button>
</div>
</div>
{/* Reset Strikes */}
{(user.strikes ?? 0) > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Strikes</p>
<button onClick={handleResetStrikes} className="btn-secondary text-xs py-1.5 flex items-center gap-1" disabled={actionLoading}>
<RotateCcw className="w-3.5 h-3.5" /> Reset Strikes ({user.strikes})
</button>
</div>
)}
{/* View Posts */}
<div className="pt-2 border-t border-warm-300">
<Link href={`/posts?author_id=${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
View User&apos;s Posts
</Link>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="card p-8 text-center text-gray-500">User not found</div>
)}
{/* Status Change Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowModal(null)}>
<div className="card p-6 w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-gray-900 mb-1">
{showModal === 'active' ? 'Activate' : showModal === 'suspended' ? 'Suspend' : 'Ban'} User
</h3>
<p className="text-sm text-gray-500 mb-4">Please provide a reason for this action.</p>
<textarea
className="input mb-4"
rows={3}
placeholder="Reason..."
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowModal(null)} className="btn-secondary text-sm">Cancel</button>
<button
onClick={() => handleStatusChange(showModal)}
className={showModal === 'banned' ? 'btn-danger text-sm' : 'btn-primary text-sm'}
disabled={actionLoading || !reason.trim()}
>
{actionLoading ? 'Processing...' : 'Confirm'}
</button>
</div>
</div>
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,145 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDate, truncate } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import Link from 'next/link';
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [offset, setOffset] = useState(0);
const limit = 25;
const fetchUsers = () => {
setLoading(true);
api.listUsers({ limit, offset, search: search || undefined, status: statusFilter || undefined })
.then((data) => { setUsers(data.users); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchUsers(); }, [offset, statusFilter]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
fetchUsers();
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="text-sm text-gray-500 mt-1">{total} total users</p>
</div>
</div>
{/* Filters */}
<div className="card p-4 mb-4 flex flex-wrap gap-3 items-center">
<form onSubmit={handleSearch} className="flex-1 min-w-[200px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
className="input pl-10"
placeholder="Search by handle, name, or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</form>
<select className="input w-auto" value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setOffset(0); }}>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
<option value="banned">Banned</option>
<option value="deactivated">Deactivated</option>
</select>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">User</th>
<th className="table-header">Email</th>
<th className="table-header">Role</th>
<th className="table-header">Status</th>
<th className="table-header">Strikes</th>
<th className="table-header">Joined</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{loading ? (
[...Array(5)].map((_, i) => (
<tr key={i}>
{[...Array(7)].map((_, j) => (
<td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>
))}
</tr>
))
) : users.length === 0 ? (
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No users found</td></tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-warm-50 transition-colors">
<td className="table-cell">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center text-brand-600 text-xs font-bold">
{(user.handle || user.email || '?')[0].toUpperCase()}
</div>
<div>
<p className="font-medium text-gray-900">{user.display_name || user.handle || '—'}</p>
<p className="text-xs text-gray-400">@{user.handle || '—'}</p>
</div>
</div>
</td>
<td className="table-cell text-gray-500">{truncate(user.email || '', 25)}</td>
<td className="table-cell">
<span className={`badge ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : user.role === 'moderator' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role || 'user'}
</span>
</td>
<td className="table-cell">
<span className={`badge ${statusColor(user.status)}`}>{user.status}</span>
</td>
<td className="table-cell">{user.strikes ?? 0}</td>
<td className="table-cell text-gray-500">{formatDate(user.created_at)}</td>
<td className="table-cell">
<Link href={`/users/${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
View
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="border-t border-warm-300 px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {offset + 1}{Math.min(offset + limit, total)} of {total}
</p>
<div className="flex gap-2">
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}>
<ChevronLeft className="w-4 h-4" />
</button>
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
</AdminShell>
);
}

View file

@ -0,0 +1,39 @@
'use client';
import { useAuth } from '@/lib/auth';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import Sidebar from './Sidebar';
export default function AdminShell({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-warm-100">
<div className="animate-pulse flex flex-col items-center gap-3">
<div className="w-10 h-10 bg-brand-500 rounded-lg" />
<p className="text-sm text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) return null;
return (
<div className="min-h-screen bg-warm-100">
<Sidebar />
<main className="ml-60 min-h-screen">
<div className="p-6">{children}</div>
</main>
</div>
);
}

View file

@ -0,0 +1,89 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { cn } from '@/lib/utils';
import {
LayoutDashboard, Users, FileText, Shield, Scale, Flag,
Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree,
} from 'lucide-react';
import { useState } from 'react';
const navItems = [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/users', label: 'Users', icon: Users },
{ href: '/posts', label: 'Posts', icon: FileText },
{ href: '/moderation', label: 'Moderation', icon: Shield },
{ href: '/appeals', label: 'Appeals', icon: Scale },
{ href: '/reports', label: 'Reports', icon: Flag },
{ href: '/algorithm', label: 'Algorithm', icon: Sliders },
{ href: '/categories', label: 'Categories', icon: FolderTree },
{ href: '/system', label: 'System Health', icon: Activity },
{ href: '/settings', label: 'Settings', icon: Settings },
];
export default function Sidebar() {
const pathname = usePathname();
const { logout } = useAuth();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
'fixed left-0 top-0 h-screen bg-white border-r border-warm-300 flex flex-col transition-all duration-300 z-30',
collapsed ? 'w-16' : 'w-60'
)}
>
{/* Logo */}
<div className="h-16 flex items-center px-4 border-b border-warm-300">
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-sm">S</span>
</div>
{!collapsed && <span className="ml-3 font-semibold text-gray-900">Sojorn Admin</span>}
</div>
{/* Navigation */}
<nav className="flex-1 py-4 overflow-y-auto">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center px-4 py-2.5 mx-2 rounded-lg text-sm font-medium transition-colors mb-0.5',
isActive
? 'bg-brand-50 text-brand-600'
: 'text-gray-600 hover:bg-warm-200 hover:text-gray-900'
)}
title={collapsed ? item.label : undefined}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="ml-3">{item.label}</span>}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="border-t border-warm-300 p-3">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center px-2 py-2 w-full rounded-lg text-sm text-gray-500 hover:bg-warm-200 transition-colors"
>
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
{!collapsed && <span className="ml-3">Collapse</span>}
</button>
<button
onClick={logout}
className="flex items-center px-2 py-2 w-full rounded-lg text-sm text-red-600 hover:bg-red-50 transition-colors mt-1"
>
<LogOut className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="ml-3">Sign Out</span>}
</button>
</div>
</aside>
);
}

231
admin/src/lib/api.ts Normal file
View file

@ -0,0 +1,231 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.sojorn.net';
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
if (token) {
if (typeof window !== 'undefined') localStorage.setItem('admin_token', token);
} else {
if (typeof window !== 'undefined') localStorage.removeItem('admin_token');
}
}
getToken(): string | null {
if (this.token) return this.token;
if (typeof window !== 'undefined') {
this.token = localStorage.getItem('admin_token');
}
return this.token;
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = this.getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (res.status === 401) {
this.setToken(null);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
// Auth
async login(email: string, password: string) {
const data = await this.request<{ access_token: string; user: any }>('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
this.setToken(data.access_token);
return data;
}
// Dashboard
async getDashboardStats() {
return this.request<any>('/api/v1/admin/dashboard');
}
async getGrowthStats(days = 30) {
return this.request<any>(`/api/v1/admin/growth?days=${days}`);
}
// Users
async listUsers(params: { limit?: number; offset?: number; search?: string; status?: string; role?: string } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.search) qs.set('search', params.search);
if (params.status) qs.set('status', params.status);
if (params.role) qs.set('role', params.role);
return this.request<any>(`/api/v1/admin/users?${qs}`);
}
async getUser(id: string) {
return this.request<any>(`/api/v1/admin/users/${id}`);
}
async updateUserStatus(id: string, status: string, reason: string) {
return this.request<any>(`/api/v1/admin/users/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status, reason }),
});
}
async updateUserRole(id: string, role: string) {
return this.request<any>(`/api/v1/admin/users/${id}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
});
}
async updateUserVerification(id: string, isOfficial: boolean, isVerified: boolean) {
return this.request<any>(`/api/v1/admin/users/${id}/verification`, {
method: 'PATCH',
body: JSON.stringify({ is_official: isOfficial, is_verified: isVerified }),
});
}
async resetUserStrikes(id: string) {
return this.request<any>(`/api/v1/admin/users/${id}/reset-strikes`, { method: 'POST' });
}
// Posts
async listPosts(params: { limit?: number; offset?: number; search?: string; status?: string; author_id?: string } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.search) qs.set('search', params.search);
if (params.status) qs.set('status', params.status);
if (params.author_id) qs.set('author_id', params.author_id);
return this.request<any>(`/api/v1/admin/posts?${qs}`);
}
async getPost(id: string) {
return this.request<any>(`/api/v1/admin/posts/${id}`);
}
async updatePostStatus(id: string, status: string, reason?: string) {
return this.request<any>(`/api/v1/admin/posts/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status, reason }),
});
}
async deletePost(id: string) {
return this.request<any>(`/api/v1/admin/posts/${id}`, { method: 'DELETE' });
}
// Moderation
async getModerationQueue(params: { limit?: number; offset?: number; status?: string } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.status) qs.set('status', params.status || 'pending');
return this.request<any>(`/api/v1/admin/moderation?${qs}`);
}
async reviewModerationFlag(id: string, action: string, reason?: string) {
return this.request<any>(`/api/v1/admin/moderation/${id}/review`, {
method: 'PATCH',
body: JSON.stringify({ action, reason }),
});
}
// Appeals
async listAppeals(params: { limit?: number; offset?: number; status?: string } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.status) qs.set('status', params.status || 'pending');
return this.request<any>(`/api/v1/admin/appeals?${qs}`);
}
async reviewAppeal(id: string, decision: string, reviewDecision: string, restoreContent = false) {
return this.request<any>(`/api/v1/admin/appeals/${id}/review`, {
method: 'PATCH',
body: JSON.stringify({ decision, review_decision: reviewDecision, restore_content: restoreContent }),
});
}
// Reports
async listReports(params: { limit?: number; offset?: number; status?: string } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.status) qs.set('status', params.status || 'pending');
return this.request<any>(`/api/v1/admin/reports?${qs}`);
}
async updateReportStatus(id: string, status: string) {
return this.request<any>(`/api/v1/admin/reports/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
// Algorithm
async getAlgorithmConfig() {
return this.request<any>('/api/v1/admin/algorithm');
}
async updateAlgorithmConfig(key: string, value: string) {
return this.request<any>('/api/v1/admin/algorithm', {
method: 'PUT',
body: JSON.stringify({ key, value }),
});
}
// Categories
async listCategories() {
return this.request<any>('/api/v1/admin/categories');
}
async createCategory(data: { slug: string; name: string; description?: string; is_sensitive?: boolean }) {
return this.request<any>('/api/v1/admin/categories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCategory(id: string, data: { name?: string; description?: string; is_sensitive?: boolean }) {
return this.request<any>(`/api/v1/admin/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// System
async getSystemHealth() {
return this.request<any>('/api/v1/admin/health');
}
async getAuditLog(params: { limit?: number; offset?: number } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
return this.request<any>(`/api/v1/admin/audit-log?${qs}`);
}
}
export const api = new ApiClient();

66
admin/src/lib/auth.tsx Normal file
View file

@ -0,0 +1,66 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { api } from './api';
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: any | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
isLoading: true,
user: null,
login: async () => {},
logout: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<any | null>(null);
useEffect(() => {
const token = api.getToken();
if (token) {
// Validate token by hitting dashboard
api.getDashboardStats()
.then(() => {
setIsAuthenticated(true);
})
.catch(() => {
api.setToken(null);
setIsAuthenticated(false);
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const data = await api.login(email, password);
setIsAuthenticated(true);
setUser(data.user);
};
const logout = () => {
api.setToken(null);
setIsAuthenticated(false);
setUser(null);
};
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

54
admin/src/lib/utils.ts Normal file
View file

@ -0,0 +1,54 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date): string {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export function formatDateTime(date: string | Date): string {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + '...';
}
export function statusColor(status: string): string {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'suspended':
return 'bg-orange-100 text-orange-800';
case 'banned':
return 'bg-red-100 text-red-800';
case 'flagged':
return 'bg-red-100 text-red-800';
case 'removed':
return 'bg-gray-100 text-gray-800';
case 'approved':
return 'bg-green-100 text-green-800';
case 'rejected':
return 'bg-red-100 text-red-800';
case 'dismissed':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
}

34
admin/tailwind.config.ts Normal file
View file

@ -0,0 +1,34 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
brand: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#6B5B95',
600: '#5a4a82',
700: '#4a3d6b',
800: '#3b3054',
900: '#2d243f',
},
warm: {
50: '#FDFCFA',
100: '#F8F7F4',
200: '#F0EFEB',
300: '#E8E6E1',
400: '#D8D6D1',
500: '#C8C6C1',
},
},
},
},
plugins: [],
};
export default config;

22
admin/tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] },
"baseUrl": "."
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -133,6 +133,7 @@ func main() {
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
analysisHandler := handlers.NewAnalysisHandler()
appealHandler := handlers.NewAppealHandler(appealService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService)
var s3Client *s3.Client
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
@ -336,8 +337,9 @@ func main() {
appeals.GET("/:id", appealHandler.GetAppeal)
}
// Admin appeal routes
// Admin appeal routes (legacy - kept for backward compat)
adminAppeals := authorized.Group("/admin/appeals")
adminAppeals.Use(middleware.AdminMiddleware(dbPool))
{
adminAppeals.GET("/pending", appealHandler.GetPendingAppeals)
adminAppeals.PATCH("/:id/review", appealHandler.ReviewAppeal)
@ -346,6 +348,57 @@ func main() {
}
}
// ──────────────────────────────────────────────
// Admin Panel API (requires auth + admin role)
// ──────────────────────────────────────────────
admin := r.Group("/api/v1/admin")
admin.Use(middleware.AuthMiddleware(cfg.JWTSecret))
admin.Use(middleware.AdminMiddleware(dbPool))
{
// Dashboard
admin.GET("/dashboard", adminHandler.GetDashboardStats)
admin.GET("/growth", adminHandler.GetGrowthStats)
// User Management
admin.GET("/users", adminHandler.ListUsers)
admin.GET("/users/:id", adminHandler.GetUser)
admin.PATCH("/users/:id/status", adminHandler.UpdateUserStatus)
admin.PATCH("/users/:id/role", adminHandler.UpdateUserRole)
admin.PATCH("/users/:id/verification", adminHandler.UpdateUserVerification)
admin.POST("/users/:id/reset-strikes", adminHandler.ResetUserStrikes)
// Post Management
admin.GET("/posts", adminHandler.ListPosts)
admin.GET("/posts/:id", adminHandler.GetPost)
admin.PATCH("/posts/:id/status", adminHandler.UpdatePostStatus)
admin.DELETE("/posts/:id", adminHandler.DeletePost)
// Moderation Queue
admin.GET("/moderation", adminHandler.GetModerationQueue)
admin.PATCH("/moderation/:id/review", adminHandler.ReviewModerationFlag)
// Appeals
admin.GET("/appeals", adminHandler.ListAppeals)
admin.PATCH("/appeals/:id/review", adminHandler.ReviewAppeal)
// Reports
admin.GET("/reports", adminHandler.ListReports)
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
// Algorithm / Feed Config
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
admin.PUT("/algorithm", adminHandler.UpdateAlgorithmConfig)
// Categories
admin.GET("/categories", adminHandler.ListCategories)
admin.POST("/categories", adminHandler.CreateCategory)
admin.PATCH("/categories/:id", adminHandler.UpdateCategory)
// System
admin.GET("/health", adminHandler.GetSystemHealth)
admin.GET("/audit-log", adminHandler.GetAuditLog)
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,

View file

@ -0,0 +1,93 @@
-- Admin Panel Support Tables
-- Algorithm configuration (key-value store for feed/moderation tuning)
CREATE TABLE IF NOT EXISTS public.algorithm_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed default algorithm config values
INSERT INTO public.algorithm_config (key, value, description) VALUES
('feed_recency_weight', '0.4', 'Weight for post recency in feed ranking'),
('feed_engagement_weight', '0.3', 'Weight for engagement metrics (likes, comments)'),
('feed_harmony_weight', '0.2', 'Weight for author harmony/trust score'),
('feed_diversity_weight', '0.1', 'Weight for content diversity in feed'),
('moderation_auto_flag_threshold', '0.7', 'AI score threshold for auto-flagging content'),
('moderation_auto_remove_threshold', '0.95', 'AI score threshold for automatic content removal'),
('moderation_greed_keyword_threshold', '0.7', 'Keyword-based spam/greed detection threshold'),
('feed_max_posts_per_author', '3', 'Max posts from same author in a single feed page'),
('feed_boost_mutual_follow', '1.5', 'Multiplier boost for posts from mutual follows'),
('feed_beacon_boost', '1.2', 'Multiplier boost for beacon posts in nearby feeds')
ON CONFLICT (key) DO NOTHING;
-- Audit log for admin actions
CREATE TABLE IF NOT EXISTS public.audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL, -- 'user', 'post', 'comment', 'appeal', 'report', 'config'
target_id UUID,
details TEXT, -- JSON string with action-specific details
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON public.audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON public.audit_log(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON public.audit_log(action);
-- Ensure profiles.role column exists (may already exist from prior migrations)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'role'
) THEN
ALTER TABLE public.profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
END IF;
END $$;
-- Ensure profiles.is_verified column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_verified'
) THEN
ALTER TABLE public.profiles ADD COLUMN is_verified BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Ensure profiles.is_private column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_private'
) THEN
ALTER TABLE public.profiles ADD COLUMN is_private BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Ensure users.status column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'status'
) THEN
ALTER TABLE public.users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
END IF;
END $$;
-- Ensure users.last_login column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_login'
) THEN
ALTER TABLE public.users ADD COLUMN last_login TIMESTAMPTZ;
END IF;
END $$;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
// AdminMiddleware checks that the authenticated user has role = 'admin' in profiles.
// Must be placed AFTER AuthMiddleware so that "user_id" is already in context.
func AdminMiddleware(pool *pgxpool.Pool) gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
var role string
err := pool.QueryRow(c.Request.Context(),
`SELECT COALESCE(role, 'user') FROM public.profiles WHERE id = $1::uuid`, userID).Scan(&role)
if err != nil {
log.Error().Err(err).Str("user_id", userID.(string)).Msg("Failed to check admin role")
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
c.Abort()
return
}
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Set("is_admin", true)
c.Next()
}
}