diff --git a/convex/_generated/api.js b/convex/_generated/api.js index 44bf985..9124e12 100644 --- a/convex/_generated/api.js +++ b/convex/_generated/api.js @@ -1,4 +1,4 @@ -/* eslint-disable */ + /** * Generated `api` utility. * diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts index f97fd19..f29136b 100644 --- a/convex/_generated/dataModel.d.ts +++ b/convex/_generated/dataModel.d.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ + /** * Generated data model types. * diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts index bec05e6..a6e81e7 100644 --- a/convex/_generated/server.d.ts +++ b/convex/_generated/server.d.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ + /** * Generated utilities for implementing server-side Convex query and mutation functions. * diff --git a/convex/_generated/server.js b/convex/_generated/server.js index bf3d25a..8df3867 100644 --- a/convex/_generated/server.js +++ b/convex/_generated/server.js @@ -1,4 +1,4 @@ -/* eslint-disable */ + /** * Generated utilities for implementing server-side Convex query and mutation functions. * diff --git a/convex/dashboard.ts b/convex/dashboard.ts index c045f64..d673a73 100644 --- a/convex/dashboard.ts +++ b/convex/dashboard.ts @@ -65,7 +65,7 @@ export const listEvents = query({ }, handler: async (ctx, args) => { if (args.search && args.search.trim().length > 0) { - let sq = ctx.db.query("events").withSearchIndex("search_name", (q) => { + const sq = ctx.db.query("events").withSearchIndex("search_name", (q) => { const base = q.search("name", args.search!); return args.type ? base.eq("type", args.type) : base; }); diff --git a/package.json b/package.json index 4da25cc..be11377 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { "node": ">=20" }, "scripts": { - "prebuild": "node -e \"const fs=require('fs'),p=require('path');fs.mkdirSync('dist',{recursive:true});const src='changelog.md',dst=p.join('public','changelog.md');if(fs.existsSync(src)){fs.mkdirSync('public',{recursive:true});fs.copyFileSync(src,dst);}\"", + "prebuild": "node scripts/generate_sitemap.mjs && node -e \"const fs=require('fs'),p=require('path');fs.mkdirSync('dist',{recursive:true});const src='changelog.md',dst=p.join('public','changelog.md');if(fs.existsSync(src)){fs.mkdirSync('public',{recursive:true});fs.copyFileSync(src,dst);}\"", "dev": "concurrently \"pnpm run dev:vite\" \"pnpm run dev:server\"", "dev:vite": "vite", "dev:server": "node server.js", diff --git a/public/robots.txt b/public/robots.txt index 14267e9..b23451e 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,3 @@ User-agent: * -Allow: / \ No newline at end of file +Allow: / +Sitemap: https://renderdragon.org/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..fa5d65a --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1 @@ +https://renderdragon.org/https://renderdragon.org/resourceshttps://renderdragon.org/blogshttps://renderdragon.org/guideshttps://renderdragon.org/faqhttps://renderdragon.org/contacthttps://renderdragon.org/showcasehttps://renderdragon.org/communityhttps://renderdragon.org/changelogshttps://renderdragon.org/utilitieshttps://renderdragon.org/generatorshttps://renderdragon.org/background-generatorhttps://renderdragon.org/text-generatorhttps://renderdragon.org/ai-title-helperhttps://renderdragon.org/youtube-downloaderhttps://renderdragon.org/player-rendererhttps://renderdragon.org/renderbothttps://renderdragon.org/native-applicationhttps://renderdragon.org/toshttps://renderdragon.org/privacyhttps://renderdragon.org/guides/scriptwritinghttps://renderdragon.org/guides/AIhttps://renderdragon.org/guides/questionshttps://renderdragon.org/guides/copyrighthttps://renderdragon.org/guides/thingstoaskhttps://renderdragon.org/guides/voice diff --git a/scripts/export_resources.ts b/scripts/export_resources.ts index 85b5748..48bd069 100644 --- a/scripts/export_resources.ts +++ b/scripts/export_resources.ts @@ -83,7 +83,7 @@ async function exportResources() { }); return acc; }, - {} as Record, + {} as Record>>, ); const mcicons = await fetchMcicons(); @@ -112,7 +112,7 @@ async function exportResources() { file: `resources/${file}`, }; allResources.push( - ...items.map((item: any) => ({ + ...items.map((item: Record) => ({ ...item, category, })), diff --git a/scripts/generate_sitemap.mjs b/scripts/generate_sitemap.mjs new file mode 100644 index 0000000..7dd8fab --- /dev/null +++ b/scripts/generate_sitemap.mjs @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from "node:fs/promises"; + +const site = "https://renderdragon.org"; +const routes = [ + "/", "/resources", "/blogs", "/guides", "/faq", "/contact", "/showcase", + "/community", "/changelogs", "/utilities", "/generators", "/background-generator", + "/text-generator", "/ai-title-helper", "/youtube-downloader", "/player-renderer", + "/renderbot", "/native-application", "/tos", "/privacy", + "/guides/scriptwriting", "/guides/AI", "/guides/questions", "/guides/copyright", "/guides/thingstoask", "/guides/voice", +]; + +const escapeXml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +const urls = routes.map((route) => `${site}${route}`); + +// Public profile and creator-pack slugs are added when build credentials are available. +if (process.env.VITE_SUPABASE_URL && process.env.VITE_SUPABASE_PUBLISHABLE_KEY) { + const headers = { apikey: process.env.VITE_SUPABASE_PUBLISHABLE_KEY }; + const fetchJson = async (path) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/${path}`, { headers, signal: controller.signal }); + return response.ok ? await response.json() : []; + } catch (error) { + console.warn(`Skipping sitemap enrichment for ${path}:`, error instanceof Error ? error.message : error); + return []; + } finally { + clearTimeout(timeout); + } + }; + const [profiles, packs, blogs] = await Promise.all([ + fetchJson("profiles?select=username&username=not.is.null"), + fetchJson("creator_packs?select=slug&status=eq.approved"), + fetchJson("blogs?select=slug&published=eq.true"), + ]); + for (const { username } of profiles) if (username) urls.push(`${site}/u/${escapeXml(username)}`); + for (const { slug } of packs) if (slug) urls.push(`${site}/creator-packs/${escapeXml(slug)}`); + for (const { slug } of blogs) if (slug) urls.push(`${site}/blogs/${escapeXml(slug)}`); +} + +await mkdir("public", { recursive: true }); +await writeFile("public/sitemap.xml", `${urls.join("")}\n`); diff --git a/src/App.tsx b/src/App.tsx index 903a147..07edb08 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { IconLoader2 } from "@tabler/icons-react"; import DonateButton from "@/components/DonateButton"; import { AdBlockDetector } from "@/components/AdBlockDetector"; import Navbar from "@/components/Navbar"; +import Seo from "@/components/Seo"; const ExternalRedirect = ({ url }: { url: string }) => { useEffect(() => { window.location.href = url; }, [url]); @@ -25,9 +26,12 @@ const GlobalComponents = () => { const location = useLocation(); const hideDonateButton = location.pathname.startsWith('/admin') || location.pathname.startsWith('/account'); + const routeName = location.pathname === '/' ? 'Minecraft Creator Tools & Resources' : + location.pathname.split('/').filter(Boolean).map((part) => part.split('-').join(' ')).join(' / '); return ( <> + letter.toUpperCase())} | RenderDragon`} description={`Explore ${routeName} on RenderDragon, free tools and resources for Minecraft content creators.`} path={location.pathname} /> {!hideDonateButton && } diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx index 9ef3ce0..3135a2e 100644 --- a/src/components/AudioPlayer.tsx +++ b/src/components/AudioPlayer.tsx @@ -1,5 +1,4 @@ import { useState, useRef, useEffect, useCallback } from 'react'; -import WaveSurfer from 'wavesurfer.js'; import { IconPlayerPlay, IconPlayerPause, @@ -23,14 +22,22 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f const [duration, setDuration] = useState(0); const [isLoading, setIsLoading] = useState(true); const [isReady, setIsReady] = useState(false); + const [loadError, setLoadError] = useState(false); + const [retryKey, setRetryKey] = useState(0); const containerRef = useRef(null); - const wavesurfer = useRef(null); + const wavesurfer = useRef(null); useEffect(() => { if (!containerRef.current) return; - const ws = WaveSurfer.create({ + let ws: import('wavesurfer.js').default | null = null; + let isMounted = true; + setLoadError(false); + setIsReady(false); + import('wavesurfer.js').then(({ default: WaveSurfer }) => { + if (!isMounted || !containerRef.current) return; + ws = WaveSurfer.create({ container: containerRef.current, waveColor: 'rgba(139, 92, 246, 0.2)', // Soft cow-purple progressColor: '#8b5cf6', // Solid cow-purple @@ -42,42 +49,52 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f barGap: 3, normalize: true, hideScrollbar: true, - }); - - let isMounted = true; - wavesurfer.current = ws; + }); + wavesurfer.current = ws; if (allowPlayBeforeReady) { setIsLoading(false); } else { setIsLoading(true); } - ws.load(src).catch((err) => { + ws.load(src).catch((err) => { if (err.name === 'AbortError') return; console.error('WaveSurfer load error:', err); - }); + if (isMounted) { + setIsLoading(false); + setLoadError(true); + } + }); - ws.on('ready', () => { + ws.on('ready', () => { if (!isMounted) return; setDuration(ws.getDuration()); setIsLoading(false); setIsReady(true); - }); + }); - ws.on('audioprocess', () => { + ws.on('audioprocess', () => { if (!isMounted) return; setCurrentTime(ws.getCurrentTime()); - }); + }); - ws.on('play', () => isMounted && setIsPlaying(true)); - ws.on('pause', () => isMounted && setIsPlaying(false)); - ws.on('finish', () => isMounted && setIsPlaying(false)); + ws.on('play', () => isMounted && setIsPlaying(true)); + ws.on('pause', () => isMounted && setIsPlaying(false)); + ws.on('finish', () => isMounted && setIsPlaying(false)); + }).catch((error: unknown) => { + if (!isMounted) return; + console.error('Failed to load WaveSurfer:', error); + setIsLoading(false); + setLoadError(true); + }); return () => { isMounted = false; - ws.destroy(); + setIsReady(false); + if (wavesurfer.current === ws) wavesurfer.current = null; + ws?.destroy(); }; - }, [src]); + }, [src, allowPlayBeforeReady, retryKey]); // Handle visibility useEffect(() => { @@ -132,6 +149,12 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f )}
+ {loadError && ( +
+ Audio preview unavailable. + +
+ )}
{/* Controls and Info */} diff --git a/src/components/FeaturedResources.tsx b/src/components/FeaturedResources.tsx index e646acd..413f6d8 100644 --- a/src/components/FeaturedResources.tsx +++ b/src/components/FeaturedResources.tsx @@ -7,6 +7,8 @@ import ResourceCard from "@/components/resources/ResourceCard"; import ResourceCardSkeleton from "./resources/ResourceCardSkeleton"; const FEATURED_CATEGORIES = ["music", "images", "sfx", "fonts"]; +type RawResource = Record; +const stringValue = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value : undefined; const FeaturedResources = () => { const [featuredResources, setFeaturedResources] = useState([]); @@ -32,18 +34,15 @@ const FeaturedResources = () => { const rawItems = await catRes.json(); const resources: Resource[] = (Array.isArray(rawItems) ? rawItems : []) .slice(0, 4) - .map((item: any, idx: number) => ({ - id: item.id ?? `${catKeys[0]}-${idx}`, - title: String(item?.title || "").trim() || `Resource ${idx + 1}`, + .map((item: RawResource, idx: number) => ({ + id: typeof item.id === "number" || typeof item.id === "string" ? item.id : `${catKeys[0]}-${idx}`, + title: stringValue(item.title) || `Resource ${idx + 1}`, category: catKeys[0] as Resource["category"], - subcategory: item.subcategory || undefined, - credit: item.credit || undefined, - filetype: item.filetype || item.ext || undefined, - download_url: item.download_url || item.url || undefined, - preview_url: item.preview_url || undefined, - image_url: item.image_url || undefined, - software: item.software || undefined, - description: item.description || undefined, + subcategory: stringValue(item.subcategory), credit: stringValue(item.credit), + filetype: stringValue(item.filetype) || stringValue(item.ext), + download_url: stringValue(item.download_url) || stringValue(item.url), + preview_url: stringValue(item.preview_url), image_url: stringValue(item.image_url), + software: stringValue(item.software), description: stringValue(item.description), })); setFeaturedResources(resources); diff --git a/src/components/InfiniteMenu.tsx b/src/components/InfiniteMenu.tsx index aec290e..d96c434 100644 --- a/src/components/InfiniteMenu.tsx +++ b/src/components/InfiniteMenu.tsx @@ -1097,8 +1097,6 @@ const InfiniteMenu: FC = ({ items = [] }) => { if (!activeItem?.link) return; if (activeItem.link.startsWith('http')) { window.open(activeItem.link, '_blank', 'noopener,noreferrer'); - } else { - } }; diff --git a/src/components/Seo.tsx b/src/components/Seo.tsx new file mode 100644 index 0000000..07654cf --- /dev/null +++ b/src/components/Seo.tsx @@ -0,0 +1,30 @@ +import { Helmet } from "react-helmet-async"; + +const SITE_URL = "https://renderdragon.org"; + +interface SeoProps { + title: string; + description: string; + path: string; + image?: string; +} + +export default function Seo({ title, description, path, image = "/ogimg.png" }: SeoProps) { + const normalizedPath = path.replace(/\/+$/, "") || "/"; + const canonical = `${SITE_URL}${normalizedPath === "/" ? "" : normalizedPath}`; + const imageUrl = image.startsWith("http") ? image : `${SITE_URL}${image}`; + return + {title} + + + + + + + + + + + + ; +} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 3d3be51..2feb972 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useRef } from 'react'; -import videojs from 'video.js'; +import React, { useEffect, useRef, useState } from 'react'; import 'video.js/dist/video-js.css'; interface VideoPlayerProps { @@ -10,6 +9,7 @@ interface VideoPlayerProps { controls?: boolean; className?: string; } +type VideoPlayerInstance = ReturnType; const VideoPlayer: React.FC = ({ src, @@ -19,24 +19,30 @@ const VideoPlayer: React.FC = ({ className = "" }) => { const videoRef = useRef(null); - const playerRef = useRef(null); + const playerRef = useRef(null); + const [loadError, setLoadError] = useState(false); + const [retryKey, setRetryKey] = useState(0); useEffect(() => { - // Make sure Video.js player is only initialized once - if (!playerRef.current) { - const videoElement = document.createElement("video-js"); + let cancelled = false; + let activePlayer: VideoPlayerInstance | null = null; + const videoElement = document.createElement("video-js"); + setLoadError(false); - videoElement.classList.add('vjs-big-play-centered'); - videoElement.classList.add('vjs-custom-skin'); - if (className) { - className.split(' ').forEach(cls => videoElement.classList.add(cls)); - } + videoElement.classList.add('vjs-big-play-centered'); + videoElement.classList.add('vjs-custom-skin'); + if (className) { + className.split(' ').forEach(cls => videoElement.classList.add(cls)); + } - if (videoRef.current) { - videoRef.current.appendChild(videoElement); - } + const initializePlayer = async () => { + if (!videoRef.current || cancelled) return; + videoRef.current.appendChild(videoElement); - const player = playerRef.current = videojs(videoElement, { + try { + const { default: videojs } = await import('video.js'); + if (cancelled) return; + const player = videojs(videoElement, { autoplay, controls, responsive: true, @@ -46,35 +52,42 @@ const VideoPlayer: React.FC = ({ }, () => { // Player is ready }); + if (cancelled) { + player.dispose(); + return; + } + activePlayer = player; + playerRef.current = player; player.on('error', () => { const error = player.error(); console.warn('VideoJS Error:', error); }); - - } else { - // Update src if it changes - const player = playerRef.current; - player.src({ src }); - if (poster) player.poster(poster); - } - }, [src, poster, autoplay, controls, className]); - - // Dispose the player on unmount - useEffect(() => { - const player = playerRef.current; + } catch (error) { + if (!cancelled) { + console.error('Failed to load Video.js:', error); + setLoadError(true); + } + } + }; + initializePlayer(); return () => { - if (player && !player.isDisposed()) { - player.dispose(); + cancelled = true; + if (activePlayer && !activePlayer.isDisposed()) { + activePlayer.dispose(); + } + if (playerRef.current === activePlayer) { playerRef.current = null; } - }; - }, [playerRef]); + videoElement.remove(); + } + }, [src, poster, autoplay, controls, className, retryKey]); return (
+ {loadError &&
Video preview unavailable.
}
); }; diff --git a/src/components/admin/AdminCreatorPacksManager.tsx b/src/components/admin/AdminCreatorPacksManager.tsx index c2da19e..1c089b4 100644 --- a/src/components/admin/AdminCreatorPacksManager.tsx +++ b/src/components/admin/AdminCreatorPacksManager.tsx @@ -22,17 +22,22 @@ const AdminCreatorPacksManager = () => { const [selectedPackId, setSelectedPackId] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); - useEffect(() => { - loadPendingPacks(); - }, []); - const loadPendingPacks = async () => { setIsLoading(true); - const packs = await fetchPendingPacks(); - setPendingPacks(packs); - setIsLoading(false); + try { + const packs = await fetchPendingPacks(); + setPendingPacks(packs); + } finally { + setIsLoading(false); + } }; + useEffect(() => { + loadPendingPacks(); + // The loader is local to this component and only runs on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleApprove = async (id: string) => { const approvedPack = await reviewPack(id, 'approved'); if (approvedPack) { diff --git a/src/components/admin/BlogEditor.tsx b/src/components/admin/BlogEditor.tsx index da312d3..925f5a6 100644 --- a/src/components/admin/BlogEditor.tsx +++ b/src/components/admin/BlogEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { supabase } from "@/integrations/supabase/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -17,8 +17,8 @@ const slugify = (text: string) => { .toLowerCase() .trim() .replace(/\s+/g, '-') - .replace(/[^\w\-]+/g, '') - .replace(/\-\-+/g, '-'); + .replace(/[^\w-]+/g, '') + .replace(/--+/g, '-'); }; export default function BlogEditor() { @@ -28,55 +28,46 @@ export default function BlogEditor() { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); - // Admin Authorization Check - const authorizedEmails = ['yamura@duck.com', 'theckie@protonmail.com', 'vovoplaygame3@gmail.com']; - const isAuthorized = user && authorizedEmails.includes(user?.email || ''); - - if (authLoading) return
; - - if (!user || !isAuthorized) { - return ; - } - + // Hooks must stay above authorization returns so their order is stable. const [title, setTitle] = useState(""); const [slug, setSlug] = useState(""); const [content, setContent] = useState(""); const [published, setPublished] = useState(false); const [preview, setPreview] = useState(false); - useEffect(() => { - if (id) { - loadBlog(id); - } - }, [id]); - - // Auto-generate slug from title if creating new - useEffect(() => { - if (!id && title) { - setSlug(slugify(title)); - } - }, [title, id]); - - const loadBlog = async (blogId: string) => { + const loadBlog = useCallback(async (blogId: string) => { setLoading(true); - const { data, error } = await supabase - .from("blogs") - .select("*") - .eq("id", blogId) - .single(); - - if (error) { - toast.error("Failed to load blog"); + try { + const { data, error } = await supabase.from("blogs").select("*").eq("id", blogId).single(); + if (error) { + toast.error("Failed to load blog"); + console.error(error); + navigate("/admin"); + } else if (data) { + setTitle(data.title); setSlug(data.slug); setContent(data.content || ""); setPublished(data.published || false); + } + } catch (error) { console.error(error); + toast.error("Failed to load blog"); navigate("/admin"); - } else if (data) { - setTitle(data.title); - setSlug(data.slug); - setContent(data.content || ""); - setPublished(data.published || false); + } finally { + setLoading(false); } - setLoading(false); - }; + }, [navigate]); + + useEffect(() => { if (id) loadBlog(id); }, [id, loadBlog]); + useEffect(() => { if (!id && title) setSlug(slugify(title)); }, [title, id]); + + // Admin Authorization Check + const authorizedEmails = ['yamura@duck.com', 'theckie@protonmail.com', 'vovoplaygame3@gmail.com']; + const isAuthorized = user && authorizedEmails.includes(user?.email || ''); + + if (authLoading) return
; + + if (!user || !isAuthorized) { + return ; + } + const handleSave = async () => { if (!title || !slug || !user) { @@ -112,9 +103,9 @@ export default function BlogEditor() { toast.success("Blog created successfully"); navigate("/admin"); // Redirect or clear form } - } catch (e: any) { + } catch (e: unknown) { console.error("Error saving blog:", e); - toast.error(`Error saving: ${e.message}`); + toast.error(`Error saving: ${e instanceof Error ? e.message : "Unknown error"}`); } finally { setSaving(false); } diff --git a/src/components/profile/FontPicker.tsx b/src/components/profile/FontPicker.tsx index 3bfa6b7..ad90cd6 100644 --- a/src/components/profile/FontPicker.tsx +++ b/src/components/profile/FontPicker.tsx @@ -17,6 +17,19 @@ interface FontOption { url: string; } +interface RawFontOption { + id?: unknown; + title?: unknown; + filename?: unknown; + url?: unknown; +} + +const isFontOption = (font: RawFontOption): font is FontOption => + typeof font.id === 'number' && + typeof font.title === 'string' && + typeof font.url === 'string' && + font.url.startsWith('https://'); + interface FontPickerProps { value: string; onFontChange: (fontFamily: string, fontUrl?: string) => void; @@ -55,18 +68,15 @@ export const FontPicker: React.FC = ({ value, onFontChange }) = const data = await res.json(); if (data && Array.isArray(data.files)) { - const validated = data.files.filter((f: any) => - typeof f.id === 'number' && - typeof f.title === 'string' && - typeof f.url === 'string' && - f.url.startsWith('https://') - ); + const validated = (data.files as RawFontOption[]) + .filter(isFontOption) + .map((font) => ({ ...font, filename: typeof font.filename === 'string' ? font.filename : font.title })); setExternalFonts(validated); } return; // Success, exit the loop and function - } catch (error: any) { + } catch (error: unknown) { clearTimeout(timeoutId); - if (error.name === 'AbortError') { + if (error instanceof DOMException && error.name === 'AbortError') { toast.error("Font library connection timed out"); break; // Stop retrying on timeout as requested (or could continue, but usually timeouts are systemic) } diff --git a/src/components/profile/ImageUpload.tsx b/src/components/profile/ImageUpload.tsx index 98ee38f..d608169 100644 --- a/src/components/profile/ImageUpload.tsx +++ b/src/components/profile/ImageUpload.tsx @@ -69,9 +69,9 @@ export const ImageUpload: React.FC = ({ onUpload(data.publicUrl); toast.success('Image uploaded successfully'); - } catch (error: any) { + } catch (error: unknown) { console.error('Upload failed:', error); - toast.error(error.message || 'Failed to upload image'); + toast.error(error instanceof Error ? error.message : 'Failed to upload image'); } finally { setUploading(false); if (fileInputRef.current) { diff --git a/src/components/profile/ProfileEditor.tsx b/src/components/profile/ProfileEditor.tsx index 2ed941a..c68bd58 100644 --- a/src/components/profile/ProfileEditor.tsx +++ b/src/components/profile/ProfileEditor.tsx @@ -171,6 +171,8 @@ const ProfileEditor: React.FC = () => { if (user) { loadProfile(); } + // Profile loading is an initial fetch; save effects below own later updates. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); // Auto-save draft to local storage @@ -219,11 +221,11 @@ const ProfileEditor: React.FC = () => { toast.info("Restored your unsaved draft."); } else { setBio(data.bio || ''); - setLinks((data.links as any) || []); - setThemeConfig((data.theme_config as any) || defaultThemeConfig); + setLinks((data.links as unknown as ProfileLink[]) || []); + setThemeConfig((data.theme_config as unknown as ProfileThemeConfig) || defaultThemeConfig); } } - } catch (error: any) { + } catch (error: unknown) { toast.error('Failed to load profile settings'); console.error(error); } finally { @@ -244,8 +246,8 @@ const ProfileEditor: React.FC = () => { .from('profiles') .update({ bio, - links: links as any, - theme_config: themeConfig as any, + links: links as unknown, + theme_config: themeConfig as unknown, username: username.trim().toLowerCase(), }) .eq('id', user.id) @@ -262,8 +264,8 @@ const ProfileEditor: React.FC = () => { localStorage.removeItem(`${DRAFT_KEY}_${user.id}`); toast.success('Profile published successfully!'); setShowShare(true); - } catch (error: any) { - toast.error(error?.message || 'Failed to save profile'); + } catch (error: unknown) { + toast.error(error instanceof Error ? error.message : 'Failed to save profile'); console.error(error); } finally { setSaving(false); @@ -531,7 +533,7 @@ const ProfileEditor: React.FC = () => { setThemeConfig({ ...themeConfig, buttonStyle: val })} + onValueChange={(val: string) => setThemeConfig({ ...themeConfig, buttonStyle: val as ProfileThemeConfig['buttonStyle'] })} > @@ -570,7 +572,7 @@ const ProfileEditor: React.FC = () => {