feat: Flutter group nav, quip repair API, share, capsule key rotation, admin groups/quips pages
Flutter: - clusters_screen: _navigateToGroup now pushes real GroupScreen (was print) - public_cluster_screen: calls GET /groups/:id/feed instead of fetchNearbyBeacons - feed_sojorn_screen: _sharePost uses share_plus - api_service: getSignedMediaUrl calls Go /media/sign endpoint - quip_repair_screen: fully rewired to Go admin API (GET /admin/quips/broken, POST repair) - private_capsule_screen: auto key rotation on open (_checkAndRotateKeysIfNeeded), _performKeyRotation helper; _CapsuleAdminPanel now ConsumerStatefulWidget with working Rotate/Invite/Remove/Settings modals Admin panel: - Sidebar: Groups & Capsules + Quip Repair links added - /groups: full group management page with member panel + deactivate/remove - /quips: quip repair page with per-row and repair-all - /algorithm: live feed scores table (lazy-loaded) - api.ts: listGroups, getGroup, deleteGroup, listGroupMembers, removeGroupMember, getBrokenQuips, repairQuip, setPostThumbnail, getFeedScores Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9aaf0d84a2
commit
15e83c6a14
|
|
@ -3,13 +3,16 @@
|
|||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sliders, Save, RefreshCw } from 'lucide-react';
|
||||
import { Sliders, Save, RefreshCw, BarChart2 } 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 [scores, setScores] = useState<any[]>([]);
|
||||
const [scoresLoading, setScoresLoading] = useState(false);
|
||||
const [showScores, setShowScores] = useState(false);
|
||||
|
||||
const fetchConfig = () => {
|
||||
setLoading(true);
|
||||
|
|
@ -35,6 +38,15 @@ export default function AlgorithmPage() {
|
|||
setSaving(null);
|
||||
};
|
||||
|
||||
const loadScores = () => {
|
||||
setScoresLoading(true);
|
||||
setShowScores(true);
|
||||
api.getFeedScores()
|
||||
.then((data) => setScores(data.scores ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setScoresLoading(false));
|
||||
};
|
||||
|
||||
const groupedConfigs = {
|
||||
feed: configs.filter((c) => c.key.startsWith('feed_')),
|
||||
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
|
||||
|
|
@ -168,6 +180,68 @@ export default function AlgorithmPage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed Scores Viewer */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="w-5 h-5 text-gray-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Live Feed Scores</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadScores}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${scoresLoading ? 'animate-spin' : ''}`} />
|
||||
{showScores ? 'Refresh' : 'Load Scores'}
|
||||
</button>
|
||||
</div>
|
||||
{showScores && (
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
{scoresLoading ? (
|
||||
<div className="p-6 text-center text-gray-400">Loading scores…</div>
|
||||
) : scores.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-400">No scored posts yet</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Post</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Total</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Engage</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Quality</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Recency</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Network</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Personal</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{scores.map((s) => (
|
||||
<tr key={s.post_id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 max-w-xs">
|
||||
<p className="truncate text-gray-800" title={s.excerpt}>{s.excerpt || '—'}</p>
|
||||
<p className="text-xs text-gray-400 font-mono">{s.post_id.slice(0, 8)}…</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-blue-700">
|
||||
{Number(s.total_score).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.engagement_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.quality_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.recency_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.network_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.personalization).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{new Date(s.updated_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
201
admin/src/app/groups/page.tsx
Normal file
201
admin/src/app/groups/page.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, Trash2, Users, RotateCcw } from 'lucide-react';
|
||||
|
||||
export default function GroupsPage() {
|
||||
const [groups, setGroups] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selectedGroup, setSelectedGroup] = useState<any | null>(null);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
const limit = 50;
|
||||
|
||||
const fetchGroups = () => {
|
||||
setLoading(true);
|
||||
api.listGroups({ search: search || undefined, limit, offset })
|
||||
.then((data) => setGroups(data.groups ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchGroups(); }, [offset]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOffset(0);
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
const openGroup = async (group: any) => {
|
||||
setSelectedGroup(group);
|
||||
setMembersLoading(true);
|
||||
try {
|
||||
const data = await api.listGroupMembers(group.id);
|
||||
setMembers(data.members ?? []);
|
||||
} catch {
|
||||
setMembers([]);
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deactivateGroup = async (id: string) => {
|
||||
if (!confirm('Deactivate this group?')) return;
|
||||
try {
|
||||
await api.deleteGroup(id);
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
if (selectedGroup?.id === id) setSelectedGroup(null);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (groupId: string, userId: string) => {
|
||||
if (!confirm('Remove this member? Key rotation will be triggered.')) return;
|
||||
try {
|
||||
await api.removeGroupMember(groupId, userId);
|
||||
setMembers((prev) => prev.filter((m) => m.user_id !== userId));
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groups & Capsules</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage community groups and E2EE capsules</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="mb-4 flex gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
className="pl-9 pr-4 py-2 border rounded-lg w-full text-sm"
|
||||
placeholder="Search groups..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-navy-600 text-white rounded-lg text-sm font-medium bg-blue-700 hover:bg-blue-800">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Groups list */}
|
||||
<div className="flex-1 bg-white rounded-xl border overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">No groups found</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Type</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600">Members</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600">Key v</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600">Rotation</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{groups.map((g) => (
|
||||
<tr
|
||||
key={g.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${selectedGroup?.id === g.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => openGroup(g)}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{g.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${g.is_private ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{g.is_private ? 'Capsule' : 'Public'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{g.member_count}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">v{g.key_version}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{g.key_rotation_needed && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">Pending</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(g.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deactivateGroup(g.id); }}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
title="Deactivate group"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="px-4 py-3 border-t flex items-center gap-3">
|
||||
<button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Prev</button>
|
||||
<button disabled={groups.length < limit} onClick={() => setOffset(offset + limit)}
|
||||
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member panel */}
|
||||
{selectedGroup && (
|
||||
<div className="w-72 bg-white rounded-xl border overflow-hidden self-start">
|
||||
<div className="px-4 py-3 border-b bg-gray-50 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-semibold text-sm text-gray-800">{selectedGroup.name}</span>
|
||||
</div>
|
||||
{membersLoading ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm">Loading members…</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm">No members</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
|
||||
{members.map((m) => (
|
||||
<li key={m.user_id} className="px-4 py-2.5 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{m.username || m.display_name}</p>
|
||||
<p className="text-xs text-gray-400">{m.role}</p>
|
||||
</div>
|
||||
{m.role !== 'owner' && (
|
||||
<button
|
||||
onClick={() => removeMember(selectedGroup.id, m.user_id)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Remove member"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{selectedGroup.key_rotation_needed && (
|
||||
<div className="px-4 py-3 border-t bg-amber-50">
|
||||
<div className="flex items-center gap-2 text-amber-700 text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Key rotation pending — will auto-complete next time an admin opens this capsule.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
127
admin/src/app/quips/page.tsx
Normal file
127
admin/src/app/quips/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw, Wrench, Play, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function QuipsPage() {
|
||||
const [quips, setQuips] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repairing, setRepairing] = useState<Set<string>>(new Set());
|
||||
const [repaired, setRepaired] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchQuips = () => {
|
||||
setLoading(true);
|
||||
api.getBrokenQuips()
|
||||
.then((data) => setQuips(data.quips ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchQuips(); }, []);
|
||||
|
||||
const repairQuip = async (quip: any) => {
|
||||
setRepairing((prev) => new Set(prev).add(quip.id));
|
||||
try {
|
||||
await api.repairQuip(quip.id);
|
||||
setRepaired((prev) => new Set(prev).add(quip.id));
|
||||
setQuips((prev) => prev.filter((q) => q.id !== quip.id));
|
||||
} catch (e: any) {
|
||||
alert(`Repair failed: ${e.message}`);
|
||||
} finally {
|
||||
setRepairing((prev) => { const s = new Set(prev); s.delete(quip.id); return s; });
|
||||
}
|
||||
};
|
||||
|
||||
const repairAll = async () => {
|
||||
const list = [...quips];
|
||||
for (const q of list) {
|
||||
await repairQuip(q);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Quip Repair</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Videos missing thumbnails — server extracts frames via FFmpeg and uploads to R2.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={fetchQuips}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" /> Reload
|
||||
</button>
|
||||
{quips.length > 0 && (
|
||||
<button
|
||||
onClick={repairAll}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
|
||||
>
|
||||
<Wrench className="w-4 h-4" /> Repair All ({quips.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repaired.size > 0 && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> {repaired.size} quip{repaired.size !== 1 ? 's' : ''} repaired this session.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||
) : quips.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
{repaired.size > 0 ? '✓ All quips repaired!' : 'No broken quips found.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Post ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Video URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{quips.map((q) => (
|
||||
<tr key={q.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">{q.id.slice(0, 8)}…</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<span className="truncate block text-xs text-gray-600" title={q.video_url}>
|
||||
{q.video_url}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(q.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => repairQuip(q)}
|
||||
disabled={repairing.has(q.id)}
|
||||
className="flex items-center gap-1.5 ml-auto px-3 py-1.5 bg-amber-500 text-white rounded-lg text-xs font-medium hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{repairing.has(q.id) ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{repairing.has(q.id) ? 'Repairing…' : 'Repair'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
||||
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
||||
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||
UserCog, ShieldAlert, Cog, Mail, MapPinned,
|
||||
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
|
@ -31,6 +31,7 @@ const navigation: NavEntry[] = [
|
|||
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
||||
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||
{ href: '/groups', label: 'Groups & Capsules', icon: Users2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -55,6 +56,7 @@ const navigation: NavEntry[] = [
|
|||
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||
{ href: '/system', label: 'System Health', icon: Activity },
|
||||
{ href: '/quips', label: 'Quip Repair', icon: Video },
|
||||
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -633,6 +633,52 @@ class ApiClient {
|
|||
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
|
||||
});
|
||||
}
|
||||
|
||||
// Groups admin
|
||||
async listGroups(params: { search?: string; limit?: number; offset?: number } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set('search', params.search);
|
||||
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/groups?${qs}`);
|
||||
}
|
||||
|
||||
async getGroup(id: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${id}`);
|
||||
}
|
||||
|
||||
async deleteGroup(id: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async listGroupMembers(groupId: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${groupId}/members`);
|
||||
}
|
||||
|
||||
async removeGroupMember(groupId: string, userId: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${groupId}/members/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Quip repair
|
||||
async getBrokenQuips(limit = 50) {
|
||||
return this.request<any>(`/api/v1/admin/quips/broken?limit=${limit}`);
|
||||
}
|
||||
|
||||
async repairQuip(postId: string) {
|
||||
return this.request<any>(`/api/v1/admin/quips/${postId}/repair`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async setPostThumbnail(postId: string, thumbnailUrl: string) {
|
||||
return this.request<any>(`/api/v1/admin/posts/${postId}/thumbnail`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ thumbnail_url: thumbnailUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
// Feed scores
|
||||
async getFeedScores(limit = 50) {
|
||||
return this.request<any>(`/api/v1/admin/feed-scores?limit=${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../services/media/ffmpeg.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../services/image_upload_service.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
|
||||
class QuipRepairScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -14,8 +10,6 @@ class QuipRepairScreen extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||
final ImageUploadService _uploadService = ImageUploadService();
|
||||
|
||||
List<Map<String, dynamic>> _brokenQuips = [];
|
||||
bool _isLoading = false;
|
||||
bool _isRepairing = false;
|
||||
|
|
@ -28,126 +22,69 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
|||
}
|
||||
|
||||
Future<void> _fetchBrokenQuips() async {
|
||||
setState(() => _isLoading = true);
|
||||
setState(() { _isLoading = true; _statusMessage = null; });
|
||||
try {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_brokenQuips = [];
|
||||
_statusMessage =
|
||||
'Quip repair is unavailable (Go API migration pending).';
|
||||
});
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final data = await api.callGoApi('/admin/quips/broken', method: 'GET');
|
||||
final quips = (data['quips'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
if (mounted) setState(() => _brokenQuips = quips);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
if (mounted) {
|
||||
setState(() => _statusMessage = 'Error loading broken quips: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _repairQuip(Map<String, dynamic> quip) async {
|
||||
setState(() {
|
||||
_isRepairing = false;
|
||||
_statusMessage =
|
||||
'Quip repair is unavailable (Go API migration pending).';
|
||||
});
|
||||
return;
|
||||
|
||||
setState(() => _isRepairing = true);
|
||||
try {
|
||||
final videoUrl = quip['video_url'] as String;
|
||||
if (videoUrl.isEmpty) throw "No Video URL";
|
||||
|
||||
// Get signed URL for the video if needed (assuming public/signed handling elsewhere)
|
||||
// FFmpeg typically handles public URLs. If private R2, we need a signed URL.
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final signedVideoUrl = await api.getSignedMediaUrl(videoUrl);
|
||||
if (signedVideoUrl == null) throw "Could not sign video URL";
|
||||
|
||||
// Generate thumbnail
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final thumbPath = '${tempDir.path}/repair_thumb_${quip['id']}.jpg';
|
||||
|
||||
// Use executeWithArguments to handle URLs with special characters safely.
|
||||
// Added reconnect flags for better handling of network streams.
|
||||
final session = await FFmpegKit.executeWithArguments([
|
||||
'-y',
|
||||
'-user_agent', 'SojornApp/1.0',
|
||||
'-reconnect', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '4294',
|
||||
'-i', signedVideoUrl,
|
||||
'-ss', '00:00:01',
|
||||
'-vframes', '1',
|
||||
'-q:v', '5',
|
||||
thumbPath
|
||||
]);
|
||||
|
||||
final returnCode = await session.getReturnCode();
|
||||
if (!ReturnCode.isSuccess(returnCode)) {
|
||||
final logs = await session.getAllLogsAsString();
|
||||
// Print in chunks if it's too long for some logcats
|
||||
|
||||
// Extract the last error message from logs if possible
|
||||
String errorDetail = "FFmpeg failed (Code: $returnCode)";
|
||||
if (logs != null && logs.contains('Error')) {
|
||||
errorDetail = logs.substring(logs.lastIndexOf('Error')).split('\n').first;
|
||||
}
|
||||
|
||||
throw errorDetail;
|
||||
}
|
||||
|
||||
final thumbFile = File(thumbPath);
|
||||
if (!await thumbFile.exists()) throw "Thumbnail file creation failed";
|
||||
|
||||
// Upload
|
||||
final thumbUrl = await _uploadService.uploadImage(thumbFile);
|
||||
|
||||
// Update Post (TODO: migrate to Go API)
|
||||
|
||||
await api.callGoApi('/admin/quips/${quip['id']}/repair', method: 'POST');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
|
||||
_statusMessage = "Fixed ${quip['id']}";
|
||||
_statusMessage = 'Fixed: ${quip['id']}';
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Repair failed: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRepairing = false;
|
||||
});
|
||||
}
|
||||
if (mounted) setState(() => _isRepairing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _repairAll() async {
|
||||
// Clone list to avoid modification issues
|
||||
final list = List<Map<String, dynamic>>.from(_brokenQuips);
|
||||
for (final quip in list) {
|
||||
if (!mounted) break;
|
||||
await _repairQuip(quip);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _statusMessage = "Repair All Complete");
|
||||
}
|
||||
if (mounted) setState(() => _statusMessage = 'Repair all complete');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Repair Thumbnails"),
|
||||
title: const Text('Repair Thumbnails'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _fetchBrokenQuips,
|
||||
tooltip: 'Reload',
|
||||
),
|
||||
if (_brokenQuips.isNotEmpty && !_isRepairing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.build),
|
||||
onPressed: _repairAll,
|
||||
tooltip: "Repair All",
|
||||
)
|
||||
tooltip: 'Repair All',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
|
|
@ -160,26 +97,31 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
|||
child: Text(_statusMessage!, textAlign: TextAlign.center),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _brokenQuips.isEmpty
|
||||
? const Center(child: Text("No missing thumbnails found."))
|
||||
: ListView.builder(
|
||||
itemCount: _brokenQuips.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _brokenQuips[index];
|
||||
return ListTile(
|
||||
title: Text(item['body'] ?? "No Caption"),
|
||||
subtitle: Text(item['created_at'].toString()),
|
||||
trailing: _isRepairing
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _repairQuip(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _brokenQuips.isEmpty
|
||||
? const Center(child: Text('No missing thumbnails found.'))
|
||||
: ListView.builder(
|
||||
itemCount: _brokenQuips.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _brokenQuips[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.videocam_off),
|
||||
title: Text(item['id'] as String? ?? ''),
|
||||
subtitle: Text(item['created_at']?.toString() ?? ''),
|
||||
trailing: _isRepairing
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.auto_fix_high),
|
||||
onPressed: () => _repairQuip(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -160,8 +160,21 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
|||
}
|
||||
|
||||
void _navigateToGroup(group_models.Group group) {
|
||||
// TODO: Navigate to group detail screen
|
||||
if (kDebugMode) print('Navigate to group: ${group.name}');
|
||||
final cluster = Cluster(
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
type: group.isPrivate ? 'private_capsule' : 'geo',
|
||||
privacy: group.isPrivate ? 'private' : 'public',
|
||||
avatarUrl: group.avatarUrl,
|
||||
memberCount: group.memberCount,
|
||||
isEncrypted: false,
|
||||
category: group.category,
|
||||
createdAt: group.createdAt,
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => GroupScreen(group: cluster)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -58,11 +58,71 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
|||
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
|
||||
}
|
||||
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
|
||||
|
||||
// Silent self-healing: rotate keys if the server flagged it
|
||||
if (key != null) _checkAndRotateKeysIfNeeded();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _unlockError = 'Failed to unlock capsule'; _isUnlocking = false; });
|
||||
}
|
||||
}
|
||||
|
||||
/// Silently check if key rotation is needed and perform it automatically.
|
||||
Future<void> _checkAndRotateKeysIfNeeded() async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET');
|
||||
final rotationNeeded = status['key_rotation_needed'] as bool? ?? false;
|
||||
if (!rotationNeeded || !mounted) return;
|
||||
// Perform rotation silently — user sees nothing
|
||||
await _performKeyRotation(api, silent: true);
|
||||
} catch (_) {
|
||||
// Non-fatal: rotation will be retried on next open
|
||||
}
|
||||
}
|
||||
|
||||
/// Full key rotation: fetch member public keys, generate new AES key,
|
||||
/// encrypt for each member, push to server.
|
||||
Future<void> _performKeyRotation(ApiService api, {bool silent = false}) async {
|
||||
// Fetch member public keys
|
||||
final keysData = await api.callGoApi(
|
||||
'/groups/${widget.capsule.id}/members/public-keys',
|
||||
method: 'GET',
|
||||
);
|
||||
final memberKeys = (keysData['keys'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
if (memberKeys.isEmpty) return;
|
||||
|
||||
final pubKeys = memberKeys.map((m) => m['public_key'] as String).toList();
|
||||
final userIds = memberKeys.map((m) => m['user_id'] as String).toList();
|
||||
|
||||
final result = await CapsuleSecurityService.rotateKeys(
|
||||
memberPublicKeysB64: pubKeys,
|
||||
memberUserIds: userIds,
|
||||
);
|
||||
|
||||
// Determine next key version
|
||||
final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET');
|
||||
final currentVersion = status['key_version'] as int? ?? 1;
|
||||
final nextVersion = currentVersion + 1;
|
||||
|
||||
final payload = result.memberKeys.entries.map((e) => {
|
||||
'user_id': e.key,
|
||||
'encrypted_key': e.value,
|
||||
'key_version': nextVersion,
|
||||
}).toList();
|
||||
|
||||
await api.callGoApi('/groups/${widget.capsule.id}/keys', method: 'POST', body: {'keys': payload});
|
||||
|
||||
// Update local cache with new key
|
||||
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, result.newCapsuleKey);
|
||||
if (mounted) setState(() => _capsuleKey = result.newCapsuleKey);
|
||||
|
||||
if (!silent && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Keys rotated successfully')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -230,7 +290,11 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
|||
context: context,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _CapsuleAdminPanel(capsule: widget.capsule),
|
||||
builder: (ctx) => _CapsuleAdminPanel(
|
||||
capsule: widget.capsule,
|
||||
capsuleKey: _capsuleKey,
|
||||
onRotateKeys: () => _performKeyRotation(ref.read(apiServiceProvider)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1009,9 +1073,141 @@ class _NewVaultNoteSheetState extends State<_NewVaultNoteSheet> {
|
|||
}
|
||||
|
||||
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||||
class _CapsuleAdminPanel extends StatelessWidget {
|
||||
class _CapsuleAdminPanel extends ConsumerStatefulWidget {
|
||||
final Cluster capsule;
|
||||
const _CapsuleAdminPanel({required this.capsule});
|
||||
final SecretKey? capsuleKey;
|
||||
final Future<void> Function() onRotateKeys;
|
||||
|
||||
const _CapsuleAdminPanel({
|
||||
required this.capsule,
|
||||
required this.capsuleKey,
|
||||
required this.onRotateKeys,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_CapsuleAdminPanel> createState() => _CapsuleAdminPanelState();
|
||||
}
|
||||
|
||||
class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _rotateKeys() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.onRotateKeys();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Rotation failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _inviteMember() async {
|
||||
final handle = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => _TextInputDialog(
|
||||
title: 'Invite Member',
|
||||
label: 'Username or @handle',
|
||||
action: 'Invite',
|
||||
),
|
||||
);
|
||||
if (handle == null || handle.isEmpty) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
|
||||
// Look up user by handle
|
||||
final userData = await api.callGoApi(
|
||||
'/users/by-handle/${handle.replaceFirst('@', '')}',
|
||||
method: 'GET',
|
||||
);
|
||||
final userId = userData['id'] as String?;
|
||||
final recipientPubKey = userData['public_key'] as String?;
|
||||
|
||||
if (userId == null) throw 'User not found';
|
||||
if (recipientPubKey == null || recipientPubKey.isEmpty) throw 'User has no public key registered';
|
||||
if (widget.capsuleKey == null) throw 'Capsule not unlocked';
|
||||
|
||||
// Encrypt the current group key for the new member
|
||||
final encryptedKey = await CapsuleSecurityService.encryptCapsuleKeyForUser(
|
||||
capsuleKey: widget.capsuleKey!,
|
||||
recipientPublicKeyB64: recipientPubKey,
|
||||
);
|
||||
|
||||
await api.callGoApi('/groups/${widget.capsule.id}/invite-member', method: 'POST', body: {
|
||||
'user_id': userId,
|
||||
'encrypted_key': encryptedKey,
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${handle.replaceFirst('@', '')} invited')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Invite failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeMember() async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
|
||||
// Load member list
|
||||
final data = await api.callGoApi('/groups/${widget.capsule.id}/members', method: 'GET');
|
||||
final members = (data['members'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
if (!mounted) return;
|
||||
|
||||
final selected = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (ctx) => _MemberPickerDialog(members: members),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Remove Member'),
|
||||
content: Text('Remove ${selected['username']}? This will trigger key rotation.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await api.callGoApi(
|
||||
'/groups/${widget.capsule.id}/members/${selected['user_id']}',
|
||||
method: 'DELETE',
|
||||
);
|
||||
// Rotate keys after removal — server already flagged it; do it now
|
||||
await widget.onRotateKeys();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Remove failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSettings() async {
|
||||
Navigator.pop(context);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => _CapsuleSettingsDialog(capsule: widget.capsule),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -1021,7 +1217,6 @@ class _CapsuleAdminPanel extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40, height: 4,
|
||||
|
|
@ -1032,49 +1227,179 @@ class _CapsuleAdminPanel extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Capsule Admin',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text('Capsule Admin',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_AdminAction(
|
||||
icon: Icons.vpn_key,
|
||||
label: 'Rotate Encryption Keys',
|
||||
subtitle: 'Generate new keys and re-encrypt for all members',
|
||||
color: const Color(0xFFFFA726),
|
||||
onTap: () { Navigator.pop(context); /* TODO: key rotation flow */ },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_add,
|
||||
label: 'Invite Member',
|
||||
subtitle: 'Encrypt the capsule key for a new member',
|
||||
color: const Color(0xFF4CAF50),
|
||||
onTap: () { Navigator.pop(context); /* TODO: invite flow */ },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_remove,
|
||||
label: 'Remove Member',
|
||||
subtitle: 'Revoke access (triggers automatic key rotation)',
|
||||
color: SojornColors.destructive,
|
||||
onTap: () { Navigator.pop(context); /* TODO: remove + rotate */ },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.settings,
|
||||
label: 'Capsule Settings',
|
||||
subtitle: 'Toggle chat, forum, and vault features',
|
||||
color: SojornColors.basicBrightNavy,
|
||||
onTap: () { Navigator.pop(context); /* TODO: settings */ },
|
||||
),
|
||||
if (_busy)
|
||||
const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: CircularProgressIndicator(),
|
||||
))
|
||||
else ...[
|
||||
_AdminAction(
|
||||
icon: Icons.vpn_key,
|
||||
label: 'Rotate Encryption Keys',
|
||||
subtitle: 'Generate new keys and re-encrypt for all members',
|
||||
color: const Color(0xFFFFA726),
|
||||
onTap: _rotateKeys,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_add,
|
||||
label: 'Invite Member',
|
||||
subtitle: 'Encrypt the capsule key for a new member',
|
||||
color: const Color(0xFF4CAF50),
|
||||
onTap: _inviteMember,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_remove,
|
||||
label: 'Remove Member',
|
||||
subtitle: 'Revoke access and rotate keys automatically',
|
||||
color: SojornColors.destructive,
|
||||
onTap: _removeMember,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.settings,
|
||||
label: 'Capsule Settings',
|
||||
subtitle: 'Toggle chat, forum, and vault features',
|
||||
color: SojornColors.basicBrightNavy,
|
||||
onTap: _openSettings,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper dialogs ─────────────────────────────────────────────────────────
|
||||
|
||||
class _TextInputDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final String label;
|
||||
final String action;
|
||||
const _TextInputDialog({required this.title, required this.label, required this.action});
|
||||
@override
|
||||
State<_TextInputDialog> createState() => _TextInputDialogState();
|
||||
}
|
||||
|
||||
class _TextInputDialogState extends State<_TextInputDialog> {
|
||||
final _ctrl = TextEditingController();
|
||||
@override
|
||||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: TextField(
|
||||
controller: _ctrl,
|
||||
decoration: InputDecoration(labelText: widget.label),
|
||||
autofocus: true,
|
||||
onSubmitted: (v) => Navigator.pop(context, v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _ctrl.text.trim()),
|
||||
child: Text(widget.action),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberPickerDialog extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> members;
|
||||
const _MemberPickerDialog({required this.members});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select Member to Remove'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: members.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final m = members[i];
|
||||
return ListTile(
|
||||
title: Text(m['username'] as String? ?? m['user_id'] as String? ?? ''),
|
||||
subtitle: Text(m['role'] as String? ?? ''),
|
||||
onTap: () => Navigator.pop(context, m),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CapsuleSettingsDialog extends ConsumerStatefulWidget {
|
||||
final Cluster capsule;
|
||||
const _CapsuleSettingsDialog({required this.capsule});
|
||||
@override
|
||||
ConsumerState<_CapsuleSettingsDialog> createState() => _CapsuleSettingsDialogState();
|
||||
}
|
||||
|
||||
class _CapsuleSettingsDialogState extends ConsumerState<_CapsuleSettingsDialog> {
|
||||
bool _chat = true;
|
||||
bool _forum = true;
|
||||
bool _vault = true;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chat = widget.capsule.settings.chat;
|
||||
_forum = widget.capsule.settings.forum;
|
||||
_vault = widget.capsule.settings.files;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.callGoApi('/groups/${widget.capsule.id}/settings', method: 'PATCH', body: {
|
||||
'chat_enabled': _chat,
|
||||
'forum_enabled': _forum,
|
||||
'vault_enabled': _vault,
|
||||
});
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Capsule Settings'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SwitchListTile(title: const Text('Chat'), value: _chat, onChanged: (v) => setState(() => _chat = v)),
|
||||
SwitchListTile(title: const Text('Forum'), value: _forum, onChanged: (v) => setState(() => _forum = v)),
|
||||
SwitchListTile(title: const Text('Vault'), value: _vault, onChanged: (v) => setState(() => _vault = v)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminAction extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
|
|
|||
|
|
@ -31,14 +31,11 @@ class _PublicClusterScreenState extends ConsumerState<PublicClusterScreen> {
|
|||
Future<void> _loadPosts() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
// TODO: Call group-specific feed endpoint when wired
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final beacons = await api.fetchNearbyBeacons(
|
||||
lat: widget.cluster.lat ?? 0,
|
||||
long: widget.cluster.lng ?? 0,
|
||||
radius: widget.cluster.radiusMeters,
|
||||
);
|
||||
if (mounted) setState(() { _posts = beacons; _isLoading = false; });
|
||||
final raw = await api.callGoApi('/groups/${widget.cluster.id}/feed', method: 'GET');
|
||||
final items = (raw['posts'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final posts = items.map((j) => Post.fromJson(j)).toList();
|
||||
if (mounted) setState(() { _posts = posts; _isLoading = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../providers/feed_refresh_provider.dart';
|
||||
import '../../models/post.dart';
|
||||
|
|
@ -165,7 +166,8 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
|||
}
|
||||
|
||||
void _sharePost(Post post) {
|
||||
// TODO: Implement share functionality
|
||||
final text = post.content.isNotEmpty ? post.content : 'Check this out on Sojorn';
|
||||
Share.share(text, subject: 'Shared from Sojorn');
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1226,15 +1226,13 @@ class ApiService {
|
|||
// =========================================================================
|
||||
|
||||
Future<String> getSignedMediaUrl(String path) async {
|
||||
// For web platform, return the original URL since signing isn't needed
|
||||
// for public CDN domains
|
||||
if (path.startsWith('http')) {
|
||||
if (path.startsWith('http')) return path;
|
||||
try {
|
||||
final data = await callGoApi('/media/sign', method: 'GET', queryParams: {'path': path});
|
||||
return data['url'] as String? ?? path;
|
||||
} catch (_) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Migrate to Go API / Nginx Signed URLs
|
||||
// TODO: Implement proper signed URL generation
|
||||
return '${ApiConfig.baseUrl}/media/signed?path=$path';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue