Refactor profile screen and organize project structure

- Extract ProfileScreen and components to features/profile
- Move shared hooks and stores to top-level
- Add RLS policies for admin permissions
- Add RELEASING.md guide
- Update .gitignore
This commit is contained in:
Jamie Brunton committed 2026-01-01 23:03:43 +00:00
1 parent df4c4d486b
commit 8f8c6e401d
79 files changed
+6689 -4944

No files matched your search

+1 -1
View File
@@ -134,7 +134,7 @@ function App() {
<Route
path="/feedback"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_FEEDBACK}>
<FeedbackPage />
</ProtectedRoute>
}
+1 -1
View File
@@ -71,7 +71,7 @@ const usersNav: NavItem[] = [
label: 'Feedback',
href: '/feedback',
icon: <MessageSquareText className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
requiredPermission: PERMISSION_KEYS.MANAGE_FEEDBACK,
},
];
+2
View File
@@ -19,6 +19,7 @@ export const PERMISSION_KEYS = {
VIEW_MATCHES: 'view_matches',
VIEW_RESPONSES: 'view_responses',
VIEW_ACTIVITY: 'view_activity',
MANAGE_FEEDBACK: 'manage_feedback',
// System permissions
MANAGE_CODES: 'manage_codes',
MANAGE_ADMINS: 'manage_admins',
@@ -40,6 +41,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
[PERMISSION_KEYS.MANAGE_FEEDBACK]: { label: 'Can manage feedback', description: 'View and respond to user feedback', group: 'users' },
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
@@ -31,6 +31,8 @@ import {
MessageCircle,
UserPlus,
Image as ImageIcon,
Video as VideoIcon,
ExternalLink,
} from 'lucide-react';
// Types
@@ -75,6 +77,7 @@ interface MessageActivity {
user_id: string;
content: string | null;
media_path: string | null;
media_type: 'image' | 'video' | null;
created_at: string;
profile: {
id: string;
@@ -232,7 +235,7 @@ export function UserActivityPage() {
const { data: messagesData, error: messagesError } = await supabase
.from('messages')
.select(`
id, user_id, content, media_path, created_at,
id, user_id, content, media_path, media_type, created_at,
match:matches(
id,
question:questions(text)
@@ -267,6 +270,7 @@ export function UserActivityPage() {
user_id: item.user_id,
content: item.content,
media_path: item.media_path,
media_type: item.media_type,
created_at: item.created_at,
profile: profilesMap[item.user_id] || null,
match: item.match ? {
@@ -545,18 +549,19 @@ export function UserActivityPage() {
<TableHead>Message</TableHead>
<TableHead>Context</TableHead>
<TableHead>Time</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading.messages ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : messages.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No messages yet.
</TableCell>
</TableRow>
@@ -567,8 +572,11 @@ export function UserActivityPage() {
<TableCell className="max-w-[300px]">
{item.media_path ? (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ImageIcon className="h-4 w-4" />
[Media]
{item.media_type === 'video' ? (
<><VideoIcon className="h-4 w-4" /> [Video]</>
) : (
<><ImageIcon className="h-4 w-4" /> [Image]</>
)}
</span>
) : (
<span className="truncate block">
@@ -584,6 +592,19 @@ export function UserActivityPage() {
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</TableCell>
<TableCell>
{item.match && item.profile ? (
<Link
to={`/users/${item.profile.id}/matches/${item.match.id}`}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View Chat
</Link>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
</TableRow>
))
)}
+80 -2
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, Check, CheckCheck, Eye } from 'lucide-react';
import { ArrowLeft, Check, CheckCheck, Eye, Video as VideoIcon } from 'lucide-react';
import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
@@ -32,6 +32,7 @@ interface Message {
user_id: string;
content: string | null;
media_path: string | null;
media_type: 'image' | 'video' | null;
created_at: string;
read_at: string | null;
media_viewed_at: string | null;
@@ -117,6 +118,79 @@ function ChatImage({ mediaPath }: { mediaPath: string }) {
}
function ChatVideo({ mediaPath }: { mediaPath: string }) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mediaPath) return;
const getUrl = async () => {
try {
// Handle legacy full URLs
let path = mediaPath;
if (path.startsWith('http')) {
const parts = path.split('/chat-media/');
if (parts.length > 1) {
path = decodeURIComponent(parts[1]);
}
}
const { data, error } = await supabase.storage
.from('chat-media')
.createSignedUrl(path, 3600);
if (error) {
console.error('Error creating signed URL for video:', error);
setError(error.message);
return;
}
if (data?.signedUrl) {
setUrl(data.signedUrl);
}
} catch (err) {
console.error('Unexpected error loading video:', err);
setError('Failed to load');
}
};
getUrl();
}, [mediaPath]);
if (error) {
return (
<div className="h-48 w-64 rounded-lg bg-muted flex flex-col items-center justify-center border border-red-200 p-2">
<VideoIcon className="h-8 w-8 text-red-400 mb-2" />
<span className="text-xs text-red-500 text-center">Failed to load video</span>
<span className="text-[10px] text-muted-foreground mt-1 text-center truncate w-full" title={error}>
{error}
</span>
</div>
);
}
if (!url) {
return (
<div className="h-48 w-64 rounded-lg bg-muted flex items-center justify-center">
<Skeleton className="h-full w-full rounded-lg" />
</div>
);
}
return (
<div className="relative">
<video
src={url}
controls
className="max-w-xs rounded-lg"
preload="metadata"
>
Your browser does not support the video tag.
</video>
</div>
);
}
export function MatchChatPage() {
const { userId, matchId } = useParams<{ userId: string; matchId: string }>();
const [user, setUser] = useState<Profile | null>(null);
@@ -337,7 +411,11 @@ export function MatchChatPage() {
{message.media_path && (
<div className="mt-2">
<div className="relative group">
<ChatImage mediaPath={message.media_path} />
{message.media_type === 'video' ? (
<ChatVideo mediaPath={message.media_path} />
) : (
<ChatImage mediaPath={message.media_path} />
)}
{message.media_viewed_at && (
<Badge
variant="secondary"