Add admin panel: backend middleware, handler, routes + Next.js frontend
This commit is contained in:
parent
0954c1e2a3
commit
96616bd81f
7
admin/.gitignore
vendored
Normal file
7
admin/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.env.local
|
||||
.env*.local
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
136
admin/README.md
Normal file
136
admin/README.md
Normal 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
217
admin/deploy_server.sh
Normal 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
12
admin/next.config.js
Normal 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
2013
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
admin/package.json
Normal file
30
admin/package.json
Normal 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
6
admin/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
173
admin/src/app/algorithm/page.tsx
Normal file
173
admin/src/app/algorithm/page.tsx
Normal 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' 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>
|
||||
);
|
||||
}
|
||||
187
admin/src/app/appeals/page.tsx
Normal file
187
admin/src/app/appeals/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
admin/src/app/categories/page.tsx
Normal file
147
admin/src/app/categories/page.tsx
Normal 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
36
admin/src/app/globals.css
Normal 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
18
admin/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
admin/src/app/login/page.tsx
Normal file
83
admin/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
admin/src/app/moderation/page.tsx
Normal file
172
admin/src/app/moderation/page.tsx
Normal 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'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
125
admin/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
admin/src/app/posts/[id]/page.tsx
Normal file
180
admin/src/app/posts/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
admin/src/app/posts/page.tsx
Normal file
145
admin/src/app/posts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
admin/src/app/reports/page.tsx
Normal file
128
admin/src/app/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
admin/src/app/settings/page.tsx
Normal file
98
admin/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
admin/src/app/system/page.tsx
Normal file
149
admin/src/app/system/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
admin/src/app/users/[id]/page.tsx
Normal file
259
admin/src/app/users/[id]/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
145
admin/src/app/users/page.tsx
Normal file
145
admin/src/app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
admin/src/components/AdminShell.tsx
Normal file
39
admin/src/components/AdminShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
admin/src/components/Sidebar.tsx
Normal file
89
admin/src/components/Sidebar.tsx
Normal 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
231
admin/src/lib/api.ts
Normal 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
66
admin/src/lib/auth.tsx
Normal 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
54
admin/src/lib/utils.ts
Normal 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
34
admin/tailwind.config.ts
Normal 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
22
admin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
1285
go-backend/internal/handlers/admin_handler.go
Normal file
1285
go-backend/internal/handlers/admin_handler.go
Normal file
File diff suppressed because it is too large
Load diff
41
go-backend/internal/middleware/admin.go
Normal file
41
go-backend/internal/middleware/admin.go
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue