feat: add ListOfficialProfiles endpoint + profiles grid in admin UI
This commit is contained in:
parent
2dae622dea
commit
3d371e965e
|
|
@ -62,26 +62,47 @@ interface Config {
|
||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OfficialProfile {
|
||||||
|
profile_id: string;
|
||||||
|
user_id: string;
|
||||||
|
handle: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
bio: string;
|
||||||
|
is_verified: boolean;
|
||||||
|
has_config: boolean;
|
||||||
|
config_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OfficialAccountsPage() {
|
export default function OfficialAccountsPage() {
|
||||||
const [configs, setConfigs] = useState<Config[]>([]);
|
const [configs, setConfigs] = useState<Config[]>([]);
|
||||||
|
const [profiles, setProfiles] = useState<OfficialProfile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [setupProfile, setSetupProfile] = useState<OfficialProfile | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||||
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message: string }>>({});
|
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message: string }>>({});
|
||||||
|
|
||||||
const fetchConfigs = async () => {
|
const fetchAll = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.listOfficialAccounts();
|
const [configData, profileData] = await Promise.all([
|
||||||
setConfigs(data.configs || []);
|
api.listOfficialAccounts(),
|
||||||
|
api.listOfficialProfiles(),
|
||||||
|
]);
|
||||||
|
setConfigs(configData.configs || []);
|
||||||
|
setProfiles(profileData.profiles || []);
|
||||||
} catch {
|
} catch {
|
||||||
setConfigs([]);
|
setConfigs([]);
|
||||||
|
setProfiles([]);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchConfigs(); }, []);
|
const fetchConfigs = fetchAll;
|
||||||
|
|
||||||
|
useEffect(() => { fetchAll(); }, []);
|
||||||
|
|
||||||
const setAction = (id: string, loading: boolean, result?: { ok: boolean; message: string }) => {
|
const setAction = (id: string, loading: boolean, result?: { ok: boolean; message: string }) => {
|
||||||
setActionLoading((p) => ({ ...p, [id]: loading }));
|
setActionLoading((p) => ({ ...p, [id]: loading }));
|
||||||
|
|
@ -152,7 +173,40 @@ export default function OfficialAccountsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <CreateAccountForm onDone={() => { setShowForm(false); fetchConfigs(); }} />}
|
{showForm && <CreateAccountForm onDone={() => { setShowForm(false); fetchAll(); }} initialProfile={setupProfile} />}
|
||||||
|
|
||||||
|
{/* Official Profiles Overview */}
|
||||||
|
{!loading && profiles.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-3">Official Profiles ({profiles.length})</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<div key={p.profile_id} className="bg-white rounded-lg border border-warm-300 p-3 flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 bg-brand-100 rounded-full flex items-center justify-center flex-shrink-0 text-brand-600 font-bold text-sm">
|
||||||
|
{p.avatar_url ? <img src={p.avatar_url} className="w-9 h-9 rounded-full object-cover" /> : p.handle[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-gray-900 text-sm truncate">@{p.handle}</span>
|
||||||
|
{p.is_verified && <CheckCircle className="w-3.5 h-3.5 text-brand-500 flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{p.display_name}</p>
|
||||||
|
</div>
|
||||||
|
{p.has_config ? (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-green-100 text-green-700 flex-shrink-0">Configured</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSetupProfile(p); setShowForm(true); }}
|
||||||
|
className="text-xs px-2 py-1 bg-brand-50 text-brand-600 rounded hover:bg-brand-100 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
Setup AI
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-gray-500">Loading...</div>
|
<div className="text-center py-12 text-gray-500">Loading...</div>
|
||||||
|
|
@ -276,8 +330,8 @@ export default function OfficialAccountsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Create Account Form ──────────────────────────────
|
// ─── Create Account Form ──────────────────────────────
|
||||||
function CreateAccountForm({ onDone }: { onDone: () => void }) {
|
function CreateAccountForm({ onDone, initialProfile }: { onDone: () => void; initialProfile?: OfficialProfile | null }) {
|
||||||
const [handle, setHandle] = useState('');
|
const [handle, setHandle] = useState(initialProfile?.handle || '');
|
||||||
const [accountType, setAccountType] = useState('general');
|
const [accountType, setAccountType] = useState('general');
|
||||||
const [modelId, setModelId] = useState('google/gemini-2.0-flash-001');
|
const [modelId, setModelId] = useState('google/gemini-2.0-flash-001');
|
||||||
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_GENERAL_PROMPT);
|
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_GENERAL_PROMPT);
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,10 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Official Accounts
|
// Official Accounts
|
||||||
|
async listOfficialProfiles() {
|
||||||
|
return this.request<any>('/api/v1/admin/official-accounts/profiles');
|
||||||
|
}
|
||||||
|
|
||||||
async listOfficialAccounts() {
|
async listOfficialAccounts() {
|
||||||
return this.request<any>('/api/v1/admin/official-accounts');
|
return this.request<any>('/api/v1/admin/official-accounts');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,7 @@ func main() {
|
||||||
|
|
||||||
// Official Accounts Management
|
// Official Accounts Management
|
||||||
admin.GET("/official-accounts", adminHandler.ListOfficialAccounts)
|
admin.GET("/official-accounts", adminHandler.ListOfficialAccounts)
|
||||||
|
admin.GET("/official-accounts/profiles", adminHandler.ListOfficialProfiles)
|
||||||
admin.GET("/official-accounts/:id", adminHandler.GetOfficialAccount)
|
admin.GET("/official-accounts/:id", adminHandler.GetOfficialAccount)
|
||||||
admin.POST("/official-accounts", adminHandler.UpsertOfficialAccount)
|
admin.POST("/official-accounts", adminHandler.UpsertOfficialAccount)
|
||||||
admin.DELETE("/official-accounts/:id", adminHandler.DeleteOfficialAccount)
|
admin.DELETE("/official-accounts/:id", adminHandler.DeleteOfficialAccount)
|
||||||
|
|
|
||||||
|
|
@ -2833,6 +2833,18 @@ func (h *AdminHandler) ListOfficialAccounts(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"configs": configs})
|
c.JSON(http.StatusOK, gin.H{"configs": configs})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListOfficialProfiles(c *gin.Context) {
|
||||||
|
profiles, err := h.officialAccountsService.ListOfficialProfiles(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if profiles == nil {
|
||||||
|
profiles = []services.OfficialProfile{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"profiles": profiles})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) GetOfficialAccount(c *gin.Context) {
|
func (h *AdminHandler) GetOfficialAccount(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id)
|
cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id)
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,50 @@ func stripHTMLTags(s string) string {
|
||||||
return strings.TrimSpace(result.String())
|
return strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OfficialProfile represents a profile with is_official = true
|
||||||
|
type OfficialProfile struct {
|
||||||
|
ProfileID string `json:"profile_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
IsVerified bool `json:"is_verified"`
|
||||||
|
HasConfig bool `json:"has_config"`
|
||||||
|
ConfigID *string `json:"config_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOfficialProfiles returns all profiles where is_official = true,
|
||||||
|
// along with whether they have an official_account_configs entry
|
||||||
|
func (s *OfficialAccountsService) ListOfficialProfiles(ctx context.Context) ([]OfficialProfile, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT p.id, p.user_id, p.handle, p.display_name, COALESCE(p.avatar_url, ''),
|
||||||
|
COALESCE(p.bio, ''), COALESCE(p.is_verified, false),
|
||||||
|
c.id AS config_id
|
||||||
|
FROM public.profiles p
|
||||||
|
LEFT JOIN official_account_configs c ON c.profile_id = p.id
|
||||||
|
WHERE p.is_official = true
|
||||||
|
ORDER BY p.handle
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var profiles []OfficialProfile
|
||||||
|
for rows.Next() {
|
||||||
|
var p OfficialProfile
|
||||||
|
var configID *string
|
||||||
|
if err := rows.Scan(&p.ProfileID, &p.UserID, &p.Handle, &p.DisplayName, &p.AvatarURL, &p.Bio, &p.IsVerified, &configID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.ConfigID = configID
|
||||||
|
p.HasConfig = configID != nil
|
||||||
|
profiles = append(profiles, p)
|
||||||
|
}
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LookupProfileID finds a profile ID by handle
|
// LookupProfileID finds a profile ID by handle
|
||||||
func (s *OfficialAccountsService) LookupProfileID(ctx context.Context, handle string) (string, error) {
|
func (s *OfficialAccountsService) LookupProfileID(ctx context.Context, handle string) (string, error) {
|
||||||
var id string
|
var id string
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue