feat: admin profile editing and follower/following management for official accounts

This commit is contained in:
Patrick Britton 2026-02-09 09:37:44 -06:00
parent 82e9246fdd
commit ceeb80df03
6 changed files with 2571 additions and 1779 deletions

View file

@ -5,7 +5,7 @@ import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils'; import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw } from 'lucide-react'; import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
export default function UserDetailPage() { export default function UserDetailPage() {
@ -255,6 +255,16 @@ export default function UserDetailPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Official Account: Editable Profile */}
{user.is_official && (
<OfficialProfileEditor user={user} onSaved={fetchUser} />
)}
{/* Official Account: Follower/Following Management */}
{user.is_official && (
<FollowManager userId={user.id} />
)}
</div> </div>
) : ( ) : (
<div className="card p-8 text-center text-gray-500">User not found</div> <div className="card p-8 text-center text-gray-500">User not found</div>
@ -322,3 +332,226 @@ export default function UserDetailPage() {
</AdminShell> </AdminShell>
); );
} }
// ─── Official Profile Editor ─────────────────────────
function OfficialProfileEditor({ user, onSaved }: { user: any; onSaved: () => void }) {
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [form, setForm] = useState({
handle: user.handle || '',
display_name: user.display_name || '',
bio: user.bio || '',
avatar_url: user.avatar_url || '',
cover_url: user.cover_url || '',
location: user.location || '',
website: user.website || '',
origin_country: user.origin_country || '',
});
useEffect(() => {
setForm({
handle: user.handle || '',
display_name: user.display_name || '',
bio: user.bio || '',
avatar_url: user.avatar_url || '',
cover_url: user.cover_url || '',
location: user.location || '',
website: user.website || '',
origin_country: user.origin_country || '',
});
}, [user]);
const handleSave = async () => {
setSaving(true);
setResult(null);
try {
await api.adminUpdateProfile(user.id, form);
setResult({ ok: true, msg: 'Profile saved' });
setEditing(false);
onSaved();
} catch (e: any) {
setResult({ ok: false, msg: e.message });
}
setSaving(false);
};
const fields: { key: keyof typeof form; label: string; type?: string }[] = [
{ key: 'handle', label: 'Handle' },
{ key: 'display_name', label: 'Display Name' },
{ key: 'bio', label: 'Bio', type: 'textarea' },
{ key: 'avatar_url', label: 'Avatar URL' },
{ key: 'cover_url', label: 'Cover URL' },
{ key: 'location', label: 'Location' },
{ key: 'website', label: 'Website' },
{ key: 'origin_country', label: 'Country' },
];
return (
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
<Pencil className="w-4 h-4" /> Official Account Profile
</h3>
{!editing ? (
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>
) : (
<div className="flex gap-2">
<button onClick={() => { setEditing(false); setResult(null); }} className="btn-secondary text-xs py-1 px-3 flex items-center gap-1">
<X className="w-3 h-3" /> Cancel
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary text-xs py-1 px-3 flex items-center gap-1">
<Save className="w-3 h-3" /> {saving ? 'Saving...' : 'Save'}
</button>
</div>
)}
</div>
{result && (
<p className={`text-xs mb-3 ${result.ok ? 'text-green-600' : 'text-red-600'}`}>{result.msg}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{fields.map((f) => (
<div key={f.key} className={f.type === 'textarea' ? 'md:col-span-2' : ''}>
<label className="block text-xs font-medium text-gray-500 mb-1">{f.label}</label>
{editing ? (
f.type === 'textarea' ? (
<textarea
value={form[f.key]}
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}
rows={3}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-sm"
/>
) : (
<input
type="text"
value={form[f.key]}
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-sm"
/>
)
) : (
<p className="text-sm text-gray-900 truncate">{String(form[f.key]) || '—'}</p>
)}
</div>
))}
</div>
</div>
);
}
// ─── Follower/Following Manager ─────────────────────────
function FollowManager({ userId }: { userId: string }) {
const [tab, setTab] = useState<'followers' | 'following'>('followers');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [addHandle, setAddHandle] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const fetchList = async (relation: 'followers' | 'following') => {
setLoading(true);
try {
const data = await api.adminListFollows(userId, relation);
setUsers(data.users || []);
} catch { setUsers([]); }
setLoading(false);
};
useEffect(() => { fetchList(tab); }, [tab, userId]);
const handleAdd = async () => {
if (!addHandle.trim()) return;
setActionLoading(true);
setResult(null);
try {
const relation = tab === 'followers' ? 'follower' : 'following';
await api.adminManageFollow(userId, 'add', addHandle.trim(), relation);
setResult({ ok: true, msg: `Added ${addHandle.trim()}` });
setAddHandle('');
fetchList(tab);
} catch (e: any) {
setResult({ ok: false, msg: e.message });
}
setActionLoading(false);
};
const handleRemove = async (targetId: string, handle: string) => {
if (!confirm(`Remove ${handle || targetId}?`)) return;
setActionLoading(true);
setResult(null);
try {
const relation = tab === 'followers' ? 'follower' : 'following';
await api.adminManageFollow(userId, 'remove', targetId, relation);
setResult({ ok: true, msg: `Removed ${handle || targetId}` });
fetchList(tab);
} catch (e: any) {
setResult({ ok: false, msg: e.message });
}
setActionLoading(false);
};
return (
<div className="card p-5">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-3">
<Users className="w-4 h-4" /> Followers &amp; Following
</h3>
{/* Tabs */}
<div className="flex gap-1 mb-3">
<button onClick={() => setTab('followers')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${tab === 'followers' ? 'bg-brand-500 text-white' : 'bg-warm-100 text-gray-600 hover:bg-warm-200'}`}>
Followers
</button>
<button onClick={() => setTab('following')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${tab === 'following' ? 'bg-brand-500 text-white' : 'bg-warm-100 text-gray-600 hover:bg-warm-200'}`}>
Following
</button>
</div>
{/* Add */}
<div className="flex items-center gap-2 mb-3">
<input type="text" placeholder="User ID to add" value={addHandle}
onChange={(e) => setAddHandle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="flex-1 px-2 py-1.5 border border-warm-300 rounded text-sm" />
<button onClick={handleAdd} disabled={actionLoading || !addHandle.trim()}
className="btn-primary text-xs py-1.5 px-3 flex items-center gap-1 disabled:opacity-50">
<UserPlus className="w-3.5 h-3.5" /> Add
</button>
</div>
{result && (
<p className={`text-xs mb-2 ${result.ok ? 'text-green-600' : 'text-red-600'}`}>{result.msg}</p>
)}
{/* List */}
<div className="max-h-60 overflow-y-auto border border-warm-200 rounded-lg">
{loading ? (
<p className="p-3 text-xs text-gray-500">Loading...</p>
) : users.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No {tab}</p>
) : (
users.map((u) => (
<div key={u.id} className="flex items-center justify-between px-3 py-2 border-b border-warm-100 last:border-0">
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 bg-brand-100 rounded-full flex items-center justify-center text-brand-600 text-xs font-bold flex-shrink-0">
{(u.handle || u.display_name || '?')[0].toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{u.display_name || u.handle || '—'}</p>
<p className="text-[10px] text-gray-500">@{u.handle || '—'}{u.is_official ? ' · Official' : ''}</p>
</div>
</div>
<button onClick={() => handleRemove(u.id, u.handle)} disabled={actionLoading}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50 disabled:opacity-50 flex-shrink-0">
<UserMinus className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
);
}

View file

@ -112,6 +112,24 @@ class ApiClient {
return this.request<any>(`/api/v1/admin/users/${id}/reset-strikes`, { method: 'POST' }); return this.request<any>(`/api/v1/admin/users/${id}/reset-strikes`, { method: 'POST' });
} }
async adminUpdateProfile(id: string, fields: Record<string, any>) {
return this.request<any>(`/api/v1/admin/users/${id}/profile`, {
method: 'PATCH',
body: JSON.stringify(fields),
});
}
async adminListFollows(id: string, relation: 'followers' | 'following', limit = 50) {
return this.request<any>(`/api/v1/admin/users/${id}/follows?relation=${relation}&limit=${limit}`);
}
async adminManageFollow(id: string, action: 'add' | 'remove', userId: string, relation: 'follower' | 'following') {
return this.request<any>(`/api/v1/admin/users/${id}/follows`, {
method: 'POST',
body: JSON.stringify({ action, user_id: userId, relation }),
});
}
// Posts // Posts
async listPosts(params: { limit?: number; offset?: number; search?: string; status?: string; author_id?: string } = {}) { async listPosts(params: { limit?: number; offset?: number; search?: string; status?: string; author_id?: string } = {}) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();

View file

@ -417,6 +417,9 @@ func main() {
admin.PATCH("/users/:id/role", adminHandler.UpdateUserRole) admin.PATCH("/users/:id/role", adminHandler.UpdateUserRole)
admin.PATCH("/users/:id/verification", adminHandler.UpdateUserVerification) admin.PATCH("/users/:id/verification", adminHandler.UpdateUserVerification)
admin.POST("/users/:id/reset-strikes", adminHandler.ResetUserStrikes) admin.POST("/users/:id/reset-strikes", adminHandler.ResetUserStrikes)
admin.PATCH("/users/:id/profile", adminHandler.AdminUpdateProfile)
admin.POST("/users/:id/follows", adminHandler.AdminManageFollow)
admin.GET("/users/:id/follows", adminHandler.AdminListFollows)
// Post Management // Post Management
admin.GET("/posts", adminHandler.ListPosts) admin.GET("/posts", adminHandler.ListPosts)

View file

@ -599,6 +599,204 @@ func (h *AdminHandler) ResetUserStrikes(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Strikes reset"}) c.JSON(http.StatusOK, gin.H{"message": "Strikes reset"})
} }
// AdminUpdateProfile allows full profile editing for official accounts.
func (h *AdminHandler) AdminUpdateProfile(c *gin.Context) {
ctx := c.Request.Context()
targetUserID := c.Param("id")
var req struct {
Handle *string `json:"handle"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
AvatarURL *string `json:"avatar_url"`
CoverURL *string `json:"cover_url"`
Location *string `json:"location"`
Website *string `json:"website"`
Country *string `json:"origin_country"`
BirthMonth *int `json:"birth_month"`
BirthYear *int `json:"birth_year"`
IsPrivate *bool `json:"is_private"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic UPDATE
sets := []string{}
args := []interface{}{}
idx := 1
addField := func(col string, val interface{}) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if req.Handle != nil {
addField("handle", *req.Handle)
}
if req.DisplayName != nil {
addField("display_name", *req.DisplayName)
}
if req.Bio != nil {
addField("bio", *req.Bio)
}
if req.AvatarURL != nil {
addField("avatar_url", *req.AvatarURL)
}
if req.CoverURL != nil {
addField("cover_url", *req.CoverURL)
}
if req.Location != nil {
addField("location", *req.Location)
}
if req.Website != nil {
addField("website", *req.Website)
}
if req.Country != nil {
addField("origin_country", *req.Country)
}
if req.BirthMonth != nil {
addField("birth_month", *req.BirthMonth)
}
if req.BirthYear != nil {
addField("birth_year", *req.BirthYear)
}
if req.IsPrivate != nil {
addField("is_private", *req.IsPrivate)
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}
sets = append(sets, fmt.Sprintf("updated_at = $%d", idx))
args = append(args, time.Now())
idx++
query := fmt.Sprintf("UPDATE profiles SET %s WHERE id = $%d::uuid", strings.Join(sets, ", "), idx)
args = append(args, targetUserID)
_, err := h.pool.Exec(ctx, query, args...)
if err != nil {
log.Error().Err(err).Str("user_id", targetUserID).Msg("Failed to update profile")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Profile updated"})
}
// AdminManageFollow adds or removes follow relationships for official accounts.
func (h *AdminHandler) AdminManageFollow(c *gin.Context) {
ctx := c.Request.Context()
targetUserID := c.Param("id")
var req struct {
Action string `json:"action"` // "add" or "remove"
UserID string `json:"user_id"` // the other user in the relationship
Relation string `json:"relation"` // "follower" (user follows target) or "following" (target follows user)
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Action != "add" && req.Action != "remove" {
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be 'add' or 'remove'"})
return
}
if req.Relation != "follower" && req.Relation != "following" {
c.JSON(http.StatusBadRequest, gin.H{"error": "relation must be 'follower' or 'following'"})
return
}
// Determine follower_id and following_id
var followerID, followingID string
if req.Relation == "follower" {
// "user follows target" — user is follower, target is following
followerID = req.UserID
followingID = targetUserID
} else {
// "target follows user" — target is follower, user is following
followerID = targetUserID
followingID = req.UserID
}
if req.Action == "add" {
_, err := h.pool.Exec(ctx, `
INSERT INTO follows (follower_id, following_id, status) VALUES ($1::uuid, $2::uuid, 'accepted')
ON CONFLICT (follower_id, following_id) DO UPDATE SET status = 'accepted'
`, followerID, followingID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add follow: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Follow added"})
} else {
_, err := h.pool.Exec(ctx, `DELETE FROM follows WHERE follower_id = $1::uuid AND following_id = $2::uuid`, followerID, followingID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove follow: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Follow removed"})
}
}
// AdminListFollows lists followers or following for a user.
func (h *AdminHandler) AdminListFollows(c *gin.Context) {
ctx := c.Request.Context()
targetUserID := c.Param("id")
relation := c.DefaultQuery("relation", "followers") // "followers" or "following"
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
var query string
if relation == "following" {
query = `
SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_official, f.created_at
FROM follows f JOIN profiles p ON p.id = f.following_id
WHERE f.follower_id = $1::uuid AND f.status = 'accepted'
ORDER BY f.created_at DESC LIMIT $2
`
} else {
query = `
SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_official, f.created_at
FROM follows f JOIN profiles p ON p.id = f.follower_id
WHERE f.following_id = $1::uuid AND f.status = 'accepted'
ORDER BY f.created_at DESC LIMIT $2
`
}
rows, err := h.pool.Query(ctx, query, targetUserID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list follows"})
return
}
defer rows.Close()
var users []gin.H
for rows.Next() {
var id uuid.UUID
var handle, displayName *string
var avatarURL *string
var isOfficial *bool
var createdAt time.Time
if err := rows.Scan(&id, &handle, &displayName, &avatarURL, &isOfficial, &createdAt); err != nil {
continue
}
users = append(users, gin.H{
"id": id, "handle": handle, "display_name": displayName,
"avatar_url": avatarURL, "is_official": isOfficial, "followed_at": createdAt,
})
}
if users == nil {
users = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"users": users, "relation": relation})
}
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// Post Management // Post Management
// ────────────────────────────────────────────── // ──────────────────────────────────────────────

3894
logo.ai

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB