Compare commits
1 Commits
d58f597ba6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b4496dca |
@@ -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>
|
||||
|
||||
|
||||
@@ -118,4 +118,3 @@ export function CollaborationTab() {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
src/hooks/useOwnerIdentity.tsx
Normal file
41
src/hooks/useOwnerIdentity.tsx
Normal 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
11
src/lib/avatar.ts
Normal 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
17
src/lib/storageUpload.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
117
supabase/migrations/20260203071000_content_storage_policies.sql
Normal file
117
supabase/migrations/20260203071000_content_storage_policies.sql
Normal 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 $$;
|
||||
|
||||
Reference in New Issue
Block a user