feat: admin profile editing and follower/following management for official accounts
This commit is contained in:
parent
82e9246fdd
commit
ceeb80df03
|
|
@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
BIN
media/official accounts/newsicon.png
Normal file
BIN
media/official accounts/newsicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Loading…
Reference in a new issue