feat: add avatar uploads and collaboration identity display

This commit is contained in:
dwindown
2026-02-03 20:30:23 +07:00
parent d58f597ba6
commit e2b4496dca
9 changed files with 457 additions and 17 deletions

View File

@@ -5,8 +5,11 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { toast } from '@/hooks/use-toast';
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X } from 'lucide-react';
import { uploadToContentStorage } from '@/lib/storageUpload';
import { resolveAvatarUrl } from '@/lib/avatar';
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X, User } from 'lucide-react';
interface HomepageFeature {
icon: string;
@@ -22,6 +25,8 @@ interface PlatformSettings {
brand_favicon_url: string;
brand_primary_color: string;
brand_accent_color: string;
owner_name: string;
owner_avatar_url: string;
homepage_headline: string;
homepage_description: string;
homepage_features: HomepageFeature[];
@@ -40,6 +45,8 @@ const emptySettings: PlatformSettings = {
brand_favicon_url: '',
brand_primary_color: '#111827',
brand_accent_color: '#0F766E',
owner_name: 'Dwindi',
owner_avatar_url: '',
homepage_headline: 'Learn. Grow. Succeed.',
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
homepage_features: defaultFeatures,
@@ -53,6 +60,7 @@ export function BrandingTab() {
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false);
const [uploadingOwnerAvatar, setUploadingOwnerAvatar] = useState(false);
// Preview states for selected files
const [logoPreview, setLogoPreview] = useState<string | null>(null);
@@ -91,6 +99,8 @@ export function BrandingTab() {
brand_favicon_url: data.brand_favicon_url || '',
brand_primary_color: data.brand_primary_color || '#111827',
brand_accent_color: data.brand_accent_color || '#0F766E',
owner_name: data.owner_name || 'Dwindi',
owner_avatar_url: data.owner_avatar_url || '',
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
homepage_description: data.homepage_description || emptySettings.homepage_description,
homepage_features: features,
@@ -109,6 +119,8 @@ export function BrandingTab() {
brand_favicon_url: settings.brand_favicon_url,
brand_primary_color: settings.brand_primary_color,
brand_accent_color: settings.brand_accent_color,
owner_name: settings.owner_name,
owner_avatar_url: settings.owner_avatar_url,
homepage_headline: settings.homepage_headline,
homepage_description: settings.homepage_description,
homepage_features: settings.homepage_features,
@@ -311,6 +323,28 @@ export function BrandingTab() {
setFaviconPreview(null);
};
const handleOwnerAvatarUpload = async (file: File) => {
if (file.size > 2 * 1024 * 1024) {
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
return;
}
try {
setUploadingOwnerAvatar(true);
const ext = file.name.split('.').pop() || 'png';
const path = `brand-assets/logo/owner-avatar-${Date.now()}.${ext}`;
const publicUrl = await uploadToContentStorage(file, path);
setSettings((prev) => ({ ...prev, owner_avatar_url: publicUrl }));
toast({ title: 'Berhasil', description: 'Avatar owner berhasil diupload' });
} catch (error) {
console.error('Owner avatar upload error:', error);
const message = error instanceof Error ? error.message : 'Gagal upload avatar owner';
toast({ title: 'Error', description: message, variant: 'destructive' });
} finally {
setUploadingOwnerAvatar(false);
}
};
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
return (
@@ -595,6 +629,54 @@ export function BrandingTab() {
</div>
</div>
</div>
<div className="border-t pt-6">
<h3 className="font-semibold mb-4">Identitas Owner</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Nama Owner</Label>
<Input
value={settings.owner_name}
onChange={(e) => setSettings({ ...settings, owner_name: e.target.value })}
placeholder="Dwindi"
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Avatar Owner</Label>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-border">
<AvatarImage src={resolveAvatarUrl(settings.owner_avatar_url) || undefined} alt={settings.owner_name} />
<AvatarFallback>
<div className="flex h-full w-full items-center justify-center rounded-full bg-muted">
<User className="h-6 w-6 text-muted-foreground" />
</div>
</AvatarFallback>
</Avatar>
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void handleOwnerAvatarUpload(file);
e.currentTarget.value = '';
}
}}
/>
<Button type="button" variant="outline" asChild disabled={uploadingOwnerAvatar}>
<span>
<Upload className="w-4 h-4 mr-2" />
{uploadingOwnerAvatar ? 'Mengupload...' : 'Upload Avatar'}
</span>
</Button>
</label>
</div>
</div>
</div>
</div>
</CardContent>
</Card>

View File

@@ -118,4 +118,3 @@ export function CollaborationTab() {
</Card>
);
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { resolveAvatarUrl } from "@/lib/avatar";
export interface OwnerIdentity {
owner_name: string;
owner_avatar_url: string;
}
const fallbackOwner: OwnerIdentity = {
owner_name: "Dwindi",
owner_avatar_url: "",
};
export function useOwnerIdentity() {
const [owner, setOwner] = useState<OwnerIdentity>(fallbackOwner);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchOwnerIdentity = async () => {
try {
const { data, error } = await supabase.functions.invoke("get-owner-identity");
if (error) throw error;
if (data) {
setOwner({
owner_name: data.owner_name || fallbackOwner.owner_name,
owner_avatar_url: resolveAvatarUrl(data.owner_avatar_url) || "",
});
}
} catch (error) {
console.error("Failed to load owner identity:", error);
} finally {
setLoading(false);
}
};
void fetchOwnerIdentity();
}, []);
return { owner, loading };
}

11
src/lib/avatar.ts Normal file
View File

@@ -0,0 +1,11 @@
import { supabase } from "@/integrations/supabase/client";
export function resolveAvatarUrl(value?: string | null): string | undefined {
if (!value) return undefined;
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) return value;
const normalized = value.startsWith("/") ? value.slice(1) : value;
const { data } = supabase.storage.from("content").getPublicUrl(normalized);
return data.publicUrl;
}

17
src/lib/storageUpload.ts Normal file
View File

@@ -0,0 +1,17 @@
import { supabase } from "@/integrations/supabase/client";
export async function uploadToContentStorage(
file: File,
path: string,
options?: { upsert?: boolean }
): Promise<string> {
const { error } = await supabase.storage.from("content").upload(path, file, {
cacheControl: "3600",
upsert: options?.upsert ?? false,
});
if (error) throw error;
const { data } = supabase.storage.from("content").getPublicUrl(path);
return data.publicUrl;
}

View File

@@ -5,15 +5,18 @@ import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCart } from '@/contexts/CartContext';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { formatIDR, formatDuration } from '@/lib/format';
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle, Lock } from 'lucide-react';
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle, Lock, User } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ReviewModal } from '@/components/reviews/ReviewModal';
import { ProductReviews } from '@/components/reviews/ProductReviews';
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
import { resolveAvatarUrl } from '@/lib/avatar';
interface Product {
id: string;
@@ -33,6 +36,7 @@ interface Product {
duration_minutes: number | null;
chapters?: { time: number; title: string; }[];
created_at: string;
collaborator_user_id?: string | null;
}
interface Module {
@@ -71,8 +75,10 @@ export default function ProductDetail() {
const [expandedLessonChapters, setExpandedLessonChapters] = useState<Set<string>>(new Set());
const [userReview, setUserReview] = useState<UserReview | null>(null);
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const [collaborator, setCollaborator] = useState<{ name: string; avatar_url: string | null } | null>(null);
const { addItem, items } = useCart();
const { user } = useAuth();
const { owner } = useOwnerIdentity();
useEffect(() => {
if (slug) fetchProduct();
@@ -93,6 +99,28 @@ export default function ProductDetail() {
}
}, [product]);
useEffect(() => {
const fetchCollaborator = async () => {
if (!product?.collaborator_user_id) {
setCollaborator(null);
return;
}
const { data } = await supabase
.from('profiles')
.select('name, avatar_url')
.eq('id', product.collaborator_user_id)
.maybeSingle();
setCollaborator({
name: data?.name || 'Builder',
avatar_url: data?.avatar_url || null,
});
};
void fetchCollaborator();
}, [product?.collaborator_user_id]);
const fetchProduct = async () => {
const { data, error } = await supabase
.from('products')
@@ -534,6 +562,7 @@ export default function ProductDetail() {
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
<div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
{product.type === 'webinar' && hasRecording() && (
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
)}
@@ -544,6 +573,33 @@ export default function ProductDetail() {
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
)}
</div>
<div className="mt-3">
{product.collaborator_user_id ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex -space-x-2">
<Avatar className="h-8 w-8 border-2 border-background">
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
</Avatar>
<Avatar className="h-8 w-8 border-2 border-background">
<AvatarImage src={resolveAvatarUrl(collaborator?.avatar_url) || undefined} alt={collaborator?.name || 'Builder'} />
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
</Avatar>
</div>
<span>
Hosted by {owner.owner_name} with {collaborator?.name || 'Builder'}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Avatar className="h-8 w-8 border border-border">
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
</Avatar>
<span>Hosted by {owner.owner_name}</span>
</div>
)}
</div>
</div>
<div className="text-right">
{product.sale_price ? (

View File

@@ -5,12 +5,15 @@ import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCart } from '@/contexts/CartContext';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { formatIDR } from '@/lib/format';
import { Video, Package, Check, Search, X } from 'lucide-react';
import { Video, Package, Check, Search, X, User } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
import { resolveAvatarUrl } from '@/lib/avatar';
interface Product {
id: string;
@@ -21,6 +24,13 @@ interface Product {
price: number;
sale_price: number | null;
is_active: boolean;
collaborator_user_id?: string | null;
}
interface CollaboratorProfile {
id: string;
name: string | null;
avatar_url: string | null;
}
interface ConsultingSettings {
@@ -35,7 +45,9 @@ export default function Products() {
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [collaborators, setCollaborators] = useState<Record<string, CollaboratorProfile>>({});
const { addItem, items } = useCart();
const { owner } = useOwnerIdentity();
useEffect(() => {
fetchData();
@@ -57,7 +69,33 @@ export default function Products() {
if (productsRes.error) {
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
} else {
setProducts(productsRes.data || []);
const productsData = productsRes.data || [];
setProducts(productsData);
const collaboratorIds = Array.from(
new Set(
productsData
.map((p) => p.collaborator_user_id)
.filter((id): id is string => !!id)
)
);
if (collaboratorIds.length > 0) {
const { data: collaboratorRows } = await supabase
.from('profiles')
.select('id, name, avatar_url')
.in('id', collaboratorIds);
if (collaboratorRows) {
const byId = collaboratorRows.reduce<Record<string, CollaboratorProfile>>((acc, row) => {
acc[row.id] = row;
return acc;
}, {});
setCollaborators(byId);
}
} else {
setCollaborators({});
}
}
if (consultingRes.data) {
@@ -232,10 +270,38 @@ export default function Products() {
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow h-full flex flex-col">
<CardHeader className="pb-4">
<div className="flex justify-between items-start gap-2 mb-2">
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[2.5rem]">{product.title}</CardTitle>
<Badge className="shrink-0">
{getTypeLabel(product.type)}
</Badge>
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[3rem]">{product.title}</CardTitle>
<div className="flex items-center gap-2">
<Badge className="shrink-0">{getTypeLabel(product.type)}</Badge>
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
</div>
</div>
<div className="mb-2">
{product.collaborator_user_id ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex -space-x-2">
<Avatar className="h-7 w-7 border-2 border-background">
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
</Avatar>
<Avatar className="h-7 w-7 border-2 border-background">
<AvatarImage src={resolveAvatarUrl(collaborators[product.collaborator_user_id]?.avatar_url) || undefined} alt={collaborators[product.collaborator_user_id]?.name || 'Collaborator'} />
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
</Avatar>
</div>
<span>
{owner.owner_name} (Host) {(collaborators[product.collaborator_user_id]?.name || 'Builder')} (Builder)
</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar className="h-7 w-7 border border-border">
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
</Avatar>
<span>{owner.owner_name}</span>
</div>
)}
</div>
<CardDescription className="line-clamp-2">
{stripHtml(product.description)}

View File

@@ -10,7 +10,10 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast';
import { User, LogOut, Phone } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Phone, Upload } from 'lucide-react';
import { uploadToContentStorage } from '@/lib/storageUpload';
import { resolveAvatarUrl } from '@/lib/avatar';
interface Profile {
id: string;
@@ -32,6 +35,7 @@ export default function MemberProfile() {
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [form, setForm] = useState({
name: '',
avatar_url: '',
@@ -113,6 +117,29 @@ export default function MemberProfile() {
setSaving(false);
};
const handleAvatarUpload = async (file: File) => {
if (!user) return;
if (file.size > 2 * 1024 * 1024) {
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
return;
}
try {
setUploadingAvatar(true);
const ext = file.name.split('.').pop() || 'png';
const path = `users/${user.id}/avatar-${Date.now()}.${ext}`;
const publicUrl = await uploadToContentStorage(file, path);
setForm((prev) => ({ ...prev, avatar_url: publicUrl }));
toast({ title: 'Berhasil', description: 'Avatar berhasil diupload' });
} catch (error) {
console.error('Avatar upload error:', error);
const message = error instanceof Error ? error.message : 'Gagal upload avatar';
toast({ title: 'Error', description: message, variant: 'destructive' });
} finally {
setUploadingAvatar(false);
}
};
const handleSignOut = async () => {
await signOut();
navigate('/');
@@ -158,13 +185,37 @@ export default function MemberProfile() {
/>
</div>
<div className="space-y-2">
<Label>URL Avatar</Label>
<Input
value={form.avatar_url}
onChange={(e) => setForm({ ...form, avatar_url: e.target.value })}
className="border-2"
placeholder="https://..."
<Label>Avatar</Label>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-border">
<AvatarImage src={resolveAvatarUrl(form.avatar_url) || undefined} alt={form.name || 'User avatar'} />
<AvatarFallback>
<div className="flex h-full w-full items-center justify-center rounded-full bg-muted">
<User className="h-6 w-6 text-muted-foreground" />
</div>
</AvatarFallback>
</Avatar>
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void handleAvatarUpload(file);
e.currentTarget.value = '';
}
}}
/>
<Button type="button" variant="outline" asChild disabled={uploadingAvatar}>
<span>
<Upload className="w-4 h-4 mr-2" />
{uploadingAvatar ? 'Mengupload...' : 'Upload Avatar'}
</span>
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<Label>Bio</Label>

View File

@@ -0,0 +1,117 @@
-- Storage policies for content bucket uploads used by:
-- - Admin branding owner avatar/logo/favicon
-- - Member profile avatar
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects'
AND policyname = 'content_public_read'
) THEN
CREATE POLICY "content_public_read"
ON storage.objects
FOR SELECT
USING (bucket_id = 'content');
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects'
AND policyname = 'content_admin_manage'
) THEN
CREATE POLICY "content_admin_manage"
ON storage.objects
FOR ALL
USING (
bucket_id = 'content'
AND EXISTS (
SELECT 1
FROM public.user_roles ur
WHERE ur.user_id = auth.uid()
AND ur.role = 'admin'
)
)
WITH CHECK (
bucket_id = 'content'
AND EXISTS (
SELECT 1
FROM public.user_roles ur
WHERE ur.user_id = auth.uid()
AND ur.role = 'admin'
)
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects'
AND policyname = 'content_user_avatar_insert'
) THEN
CREATE POLICY "content_user_avatar_insert"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'content'
AND name LIKE ('users/' || auth.uid()::text || '/%')
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects'
AND policyname = 'content_user_avatar_update'
) THEN
CREATE POLICY "content_user_avatar_update"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'content'
AND name LIKE ('users/' || auth.uid()::text || '/%')
)
WITH CHECK (
bucket_id = 'content'
AND name LIKE ('users/' || auth.uid()::text || '/%')
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects'
AND policyname = 'content_user_avatar_delete'
) THEN
CREATE POLICY "content_user_avatar_delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'content'
AND name LIKE ('users/' || auth.uid()::text || '/%')
);
END IF;
END $$;