From 63428c0b212986db80b47d7dbf6e6056d656bf69 Mon Sep 17 00:00:00 2001
From: Coder-soft
Date: Sat, 8 Aug 2026 09:23:59 +0500
Subject: [PATCH 1/2] feat: add SEO meta tags, canonical URLs, and sitemap
- Generate sitemap.xml at build time and reference it in robots.txt
- Add global Seo component with og/twitter meta tags
- Lazy-load video.js, wavesurfer.js, and generator tabs
- Add dismissible resources announcement banner
- Tighten TypeScript types and fix lint warnings
---
convex/_generated/api.js | 2 +-
convex/_generated/dataModel.d.ts | 2 +-
convex/_generated/server.d.ts | 2 +-
convex/_generated/server.js | 2 +-
convex/dashboard.ts | 2 +-
package.json | 2 +-
public/robots.txt | 3 +-
public/sitemap.xml | 1 +
scripts/export_resources.ts | 4 +-
scripts/generate_sitemap.mjs | 28 ++++++++
src/App.tsx | 4 ++
src/components/AudioPlayer.tsx | 36 ++++++-----
src/components/FeaturedResources.tsx | 2 +-
src/components/InfiniteMenu.tsx | 2 -
src/components/Seo.tsx | 29 +++++++++
src/components/VideoPlayer.tsx | 8 ++-
.../admin/AdminCreatorPacksManager.tsx | 10 +--
src/components/admin/BlogEditor.tsx | 64 +++++++------------
src/components/profile/FontPicker.tsx | 4 +-
src/components/profile/ImageUpload.tsx | 2 +-
src/components/profile/ProfileEditor.tsx | 20 +++---
src/components/profile/SvglPicker.tsx | 2 +-
.../resources/ResourceAnnouncementBanner.tsx | 21 ++++++
src/components/resources/ResourceCard.tsx | 11 ++--
src/hooks/useProfile.ts | 4 +-
src/hooks/useUserFavorites.ts | 2 +-
src/lib/showcases.ts | 8 +--
src/main.tsx | 2 +-
src/pages/BackgroundGenerator.tsx | 6 +-
src/pages/BlogView.tsx | 2 +-
src/pages/Blogs.tsx | 4 +-
src/pages/Generators.tsx | 12 ++--
src/pages/Profile.tsx | 4 +-
src/pages/ResourcesHub.tsx | 9 +--
src/pages/Showcase.tsx | 2 +-
vite.config.ts | 4 +-
36 files changed, 199 insertions(+), 123 deletions(-)
create mode 100644 public/sitemap.xml
create mode 100644 scripts/generate_sitemap.mjs
create mode 100644 src/components/Seo.tsx
create mode 100644 src/components/resources/ResourceAnnouncementBanner.tsx
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..be68d18 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"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..4bb1868
--- /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/music-copyrighthttps://renderdragon.org/toshttps://renderdragon.org/privacy
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..b64844b
--- /dev/null
+++ b/scripts/generate_sitemap.mjs
@@ -0,0 +1,28 @@
+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", "/music-copyright", "/tos", "/privacy",
+];
+
+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 [profiles, packs, blogs] = await Promise.all([
+ fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/profiles?select=username&username=not.is.null`, { headers }),
+ fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/creator_packs?select=slug&is_public=eq.true`, { headers }),
+ fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/blogs?select=slug&published=eq.true`, { headers }),
+ ]);
+ if (profiles.ok) for (const { username } of await profiles.json()) if (username) urls.push(`${site}/u/${escapeXml(username)}`);
+ if (packs.ok) for (const { slug } of await packs.json()) if (slug) urls.push(`${site}/creator-packs/${escapeXml(slug)}`);
+ if (blogs.ok) for (const { slug } of await blogs.json()) 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..515eaf6 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.replaceAll('-', ' ')).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..454a52e 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,
@@ -25,12 +24,16 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f
const [isReady, setIsReady] = useState(false);
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;
+ 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,10 +45,8 @@ 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);
@@ -55,29 +56,30 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f
ws.load(src).catch((err) => {
if (err.name === 'AbortError') return;
console.error('WaveSurfer load error:', err);
- });
+ });
- 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));
+ });
return () => {
isMounted = false;
- ws.destroy();
+ ws?.destroy();
};
- }, [src]);
+ }, [src, allowPlayBeforeReady]);
// Handle visibility
useEffect(() => {
diff --git a/src/components/FeaturedResources.tsx b/src/components/FeaturedResources.tsx
index e646acd..6c3e353 100644
--- a/src/components/FeaturedResources.tsx
+++ b/src/components/FeaturedResources.tsx
@@ -32,7 +32,7 @@ const FeaturedResources = () => {
const rawItems = await catRes.json();
const resources: Resource[] = (Array.isArray(rawItems) ? rawItems : [])
.slice(0, 4)
- .map((item: any, idx: number) => ({
+ .map((item: Record, idx: number) => ({
id: item.id ?? `${catKeys[0]}-${idx}`,
title: String(item?.title || "").trim() || `Resource ${idx + 1}`,
category: catKeys[0] as Resource["category"],
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..9471e4d
--- /dev/null
+++ b/src/components/Seo.tsx
@@ -0,0 +1,29 @@
+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 canonical = `${SITE_URL}${path === "/" ? "" : path}`;
+ 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..714b6ab 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 'video.js/dist/video-js.css';
interface VideoPlayerProps {
@@ -19,9 +18,10 @@ const VideoPlayer: React.FC = ({
className = ""
}) => {
const videoRef = useRef(null);
- const playerRef = useRef(null);
+ const playerRef = useRef(null);
useEffect(() => {
+ const initializePlayer = async () => {
// Make sure Video.js player is only initialized once
if (!playerRef.current) {
const videoElement = document.createElement("video-js");
@@ -36,6 +36,8 @@ const VideoPlayer: React.FC = ({
videoRef.current.appendChild(videoElement);
}
+ const { default: videojs } = await import('video.js');
+ if (!videoRef.current) return;
const player = playerRef.current = videojs(videoElement, {
autoplay,
controls,
@@ -58,6 +60,8 @@ const VideoPlayer: React.FC = ({
player.src({ src });
if (poster) player.poster(poster);
}
+ };
+ initializePlayer();
}, [src, poster, autoplay, controls, className]);
// Dispose the player on unmount
diff --git a/src/components/admin/AdminCreatorPacksManager.tsx b/src/components/admin/AdminCreatorPacksManager.tsx
index c2da19e..ee55af1 100644
--- a/src/components/admin/AdminCreatorPacksManager.tsx
+++ b/src/components/admin/AdminCreatorPacksManager.tsx
@@ -22,10 +22,6 @@ const AdminCreatorPacksManager = () => {
const [selectedPackId, setSelectedPackId] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
- useEffect(() => {
- loadPendingPacks();
- }, []);
-
const loadPendingPacks = async () => {
setIsLoading(true);
const packs = await fetchPendingPacks();
@@ -33,6 +29,12 @@ const AdminCreatorPacksManager = () => {
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..e2bf097 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,39 @@ 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();
-
+ 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);
+ setTitle(data.title); setSlug(data.slug); setContent(data.content || ""); setPublished(data.published || 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 +96,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..21da7cb 100644
--- a/src/components/profile/FontPicker.tsx
+++ b/src/components/profile/FontPicker.tsx
@@ -55,7 +55,7 @@ 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) =>
+ const validated = data.files.filter((f: { name?: string; url?: string }) =>
typeof f.id === 'number' &&
typeof f.title === 'string' &&
typeof f.url === 'string' &&
@@ -64,7 +64,7 @@ export const FontPicker: React.FC = ({ value, onFontChange }) =
setExternalFonts(validated);
}
return; // Success, exit the loop and function
- } catch (error: any) {
+ } catch (error: unknown) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
toast.error("Font library connection timed out");
diff --git a/src/components/profile/ImageUpload.tsx b/src/components/profile/ImageUpload.tsx
index 98ee38f..e35d0b2 100644
--- a/src/components/profile/ImageUpload.tsx
+++ b/src/components/profile/ImageUpload.tsx
@@ -69,7 +69,7 @@ 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');
} finally {
diff --git a/src/components/profile/ProfileEditor.tsx b/src/components/profile/ProfileEditor.tsx
index 2ed941a..151a7ff 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,7 +264,7 @@ const ProfileEditor: React.FC = () => {
localStorage.removeItem(`${DRAFT_KEY}_${user.id}`);
toast.success('Profile published successfully!');
setShowShare(true);
- } catch (error: any) {
+ } catch (error: unknown) {
toast.error(error?.message || 'Failed to save profile');
console.error(error);
} finally {
@@ -531,7 +533,7 @@ const ProfileEditor: React.FC = () => {
+ Loading generator...}>
@@ -66,6 +67,7 @@ const Generators = () => {
+
@@ -76,4 +78,4 @@ const Generators = () => {
);
};
-export default Generators;
\ No newline at end of file
+export default Generators;
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index 63f3e91..7bee528 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -22,7 +22,7 @@ type ProfileData = {
bio: string | null;
links: ProfileLink[] | null;
theme_config: ProfileThemeConfig | null;
- social_links: any | null;
+ social_links: Record | null;
verified: boolean | null;
};
@@ -58,7 +58,7 @@ const ProfilePage: React.FC = () => {
if (error) throw error;
if (active) {
- setProfile(data as any); // Cast because of JSON types
+ setProfile(data as unknown as ProfileData); // Supabase JSON columns need runtime validation.
}
} catch (err) {
console.error(err);
diff --git a/src/pages/ResourcesHub.tsx b/src/pages/ResourcesHub.tsx
index ad9fe51..999591b 100644
--- a/src/pages/ResourcesHub.tsx
+++ b/src/pages/ResourcesHub.tsx
@@ -17,8 +17,7 @@ import CreatorPacksTab from '@/components/resources/CreatorPacksTab';
import MusicPacksTab from '@/components/resources/MusicPacksTab';
import MusicMoodFilter from '@/components/resources/MusicMoodFilter';
import MinecraftMusicFilter from '@/components/resources/MinecraftMusicFilter';
-import MinecraftChangelogPopup from '@/components/resources/MinecraftChangelogPopup';
-import GreenVoidPopup from '@/components/resources/GreenVoidPopup';
+import ResourceAnnouncementBanner from '@/components/resources/ResourceAnnouncementBanner';
import McSoundsBrowser from '@/components/resources/McSoundsBrowser';
import McIconsBrowser from '@/components/resources/McIconsBrowser';
import AuthDialog from '@/components/auth/AuthDialog';
@@ -107,7 +106,7 @@ const ResourcesHub = () => {
if (!resourceMoods) return false;
return selectedMoods.some(mood => resourceMoods.includes(mood));
});
- }, [filteredResources, isMusicView, selectedMoods, musicMoodsData]);
+ }, [filteredResources, isMusicView, isMinecraftMusicView, selectedMoods, musicMoodsData]);
const mcsoundsResourceCount = useMemo(() => {
if (!isMcSoundsView) return {};
@@ -383,6 +382,7 @@ const ResourcesHub = () => {
+
{
open={authDialogOpen}
onOpenChange={setAuthDialogOpen}
/>
-
-
-
diff --git a/src/pages/Showcase.tsx b/src/pages/Showcase.tsx
index b974e6d..e8dba3d 100644
--- a/src/pages/Showcase.tsx
+++ b/src/pages/Showcase.tsx
@@ -362,7 +362,7 @@ const ShowcasePage: React.FC = () => {
setFilePreview(null);
setOpen(false);
await queryClient.invalidateQueries({ queryKey: ['showcases'] });
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Failed to create showcase:", error);
toast({
variant: "destructive",
diff --git a/vite.config.ts b/vite.config.ts
index 9843025..8babdc8 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -61,8 +61,6 @@ export default defineConfig(({ mode }) => {
manualChunks: {
'vendor-framer': ['framer-motion'],
'vendor-icons': ['@tabler/icons-react'],
- 'vendor-video': ['video.js'],
- 'vendor-audio': ['wavesurfer.js'],
},
},
},
@@ -71,4 +69,4 @@ export default defineConfig(({ mode }) => {
'process.env': env
}
};
-});
\ No newline at end of file
+});
From 5f9aa8e0d0e0925869e0269211d7fa9836d26667 Mon Sep 17 00:00:00 2001
From: Coder-soft
Date: Sat, 8 Aug 2026 10:26:22 +0500
Subject: [PATCH 2/2] fix: harden player error handling and API type safety
- add retry UI + failure state to audio/video preview players
- validate svgl, font, texture, and profile API payloads at runtime
- add guide routes to sitemap and make enrichment fetches timeout-safe
- normalize trailing slashes in canonical URLs and fix route name splitting
- point announcement Explore button at the music category
---
package.json | 1 +
public/sitemap.xml | 2 +-
scripts/generate_sitemap.mjs | 28 +++++--
src/App.tsx | 2 +-
src/components/AudioPlayer.tsx | 25 ++++++-
src/components/FeaturedResources.tsx | 21 +++---
src/components/Seo.tsx | 3 +-
src/components/VideoPlayer.tsx | 73 +++++++++++--------
.../admin/AdminCreatorPacksManager.tsx | 9 ++-
src/components/admin/BlogEditor.tsx | 19 +++--
src/components/profile/FontPicker.tsx | 24 ++++--
src/components/profile/ImageUpload.tsx | 2 +-
src/components/profile/ProfileEditor.tsx | 2 +-
src/components/profile/SvglPicker.tsx | 57 ++++++++++-----
.../resources/ResourceAnnouncementBanner.tsx | 11 ++-
src/components/resources/ResourceCard.tsx | 15 ++--
src/hooks/useProfile.ts | 3 +-
src/hooks/useUserFavorites.ts | 1 -
src/integrations/supabase/types.d.ts | 12 +++
src/lib/showcases.ts | 3 +-
src/pages/BackgroundGenerator.tsx | 11 ++-
src/pages/Profile.tsx | 14 +++-
src/pages/ResourcesHub.tsx | 2 +-
src/pages/Showcase.tsx | 4 +-
24 files changed, 233 insertions(+), 111 deletions(-)
create mode 100644 src/integrations/supabase/types.d.ts
diff --git a/package.json b/package.json
index be68d18..be11377 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "engines": { "node": ">=20" },
"scripts": {
"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\"",
diff --git a/public/sitemap.xml b/public/sitemap.xml
index 4bb1868..fa5d65a 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -1 +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/music-copyrighthttps://renderdragon.org/toshttps://renderdragon.org/privacy
+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/generate_sitemap.mjs b/scripts/generate_sitemap.mjs
index b64844b..7dd8fab 100644
--- a/scripts/generate_sitemap.mjs
+++ b/scripts/generate_sitemap.mjs
@@ -5,7 +5,8 @@ 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", "/music-copyright", "/tos", "/privacy",
+ "/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("'", "'");
@@ -14,14 +15,27 @@ 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([
- fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/profiles?select=username&username=not.is.null`, { headers }),
- fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/creator_packs?select=slug&is_public=eq.true`, { headers }),
- fetch(`${process.env.VITE_SUPABASE_URL}/rest/v1/blogs?select=slug&published=eq.true`, { headers }),
+ fetchJson("profiles?select=username&username=not.is.null"),
+ fetchJson("creator_packs?select=slug&status=eq.approved"),
+ fetchJson("blogs?select=slug&published=eq.true"),
]);
- if (profiles.ok) for (const { username } of await profiles.json()) if (username) urls.push(`${site}/u/${escapeXml(username)}`);
- if (packs.ok) for (const { slug } of await packs.json()) if (slug) urls.push(`${site}/creator-packs/${escapeXml(slug)}`);
- if (blogs.ok) for (const { slug } of await blogs.json()) if (slug) urls.push(`${site}/blogs/${escapeXml(slug)}`);
+ 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 });
diff --git a/src/App.tsx b/src/App.tsx
index 515eaf6..07edb08 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -27,7 +27,7 @@ const GlobalComponents = () => {
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.replaceAll('-', ' ')).join(' / ');
+ location.pathname.split('/').filter(Boolean).map((part) => part.split('-').join(' ')).join(' / ');
return (
<>
diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx
index 454a52e..3135a2e 100644
--- a/src/components/AudioPlayer.tsx
+++ b/src/components/AudioPlayer.tsx
@@ -22,6 +22,8 @@ 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);
@@ -31,6 +33,8 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f
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({
@@ -53,9 +57,13 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f
} 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', () => {
@@ -73,13 +81,20 @@ const AudioPlayer = ({ src, className, isInView = true, allowPlayBeforeReady = f
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;
+ setIsReady(false);
+ if (wavesurfer.current === ws) wavesurfer.current = null;
ws?.destroy();
};
- }, [src, allowPlayBeforeReady]);
+ }, [src, allowPlayBeforeReady, retryKey]);
// Handle visibility
useEffect(() => {
@@ -134,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 6c3e353..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: Record, 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/Seo.tsx b/src/components/Seo.tsx
index 9471e4d..07654cf 100644
--- a/src/components/Seo.tsx
+++ b/src/components/Seo.tsx
@@ -10,7 +10,8 @@ interface SeoProps {
}
export default function Seo({ title, description, path, image = "/ogimg.png" }: SeoProps) {
- const canonical = `${SITE_URL}${path === "/" ? "" : path}`;
+ 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 714b6ab..2feb972 100644
--- a/src/components/VideoPlayer.tsx
+++ b/src/components/VideoPlayer.tsx
@@ -1,5 +1,5 @@
-import React, { useEffect, useRef } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import 'video.js/dist/video-js.css';
interface VideoPlayerProps {
@@ -9,6 +9,7 @@ interface VideoPlayerProps {
controls?: boolean;
className?: string;
}
+type VideoPlayerInstance = ReturnType;
const VideoPlayer: React.FC = ({
src,
@@ -18,27 +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(() => {
- const initializePlayer = async () => {
- // 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);
+ try {
const { default: videojs } = await import('video.js');
- if (!videoRef.current) return;
- const player = playerRef.current = videojs(videoElement, {
+ if (cancelled) return;
+ const player = videojs(videoElement, {
autoplay,
controls,
responsive: true,
@@ -48,37 +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);
- }
+ } catch (error) {
+ if (!cancelled) {
+ console.error('Failed to load Video.js:', error);
+ setLoadError(true);
+ }
+ }
};
initializePlayer();
- }, [src, poster, autoplay, controls, className]);
-
- // Dispose the player on unmount
- useEffect(() => {
- const player = playerRef.current;
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 ee55af1..1c089b4 100644
--- a/src/components/admin/AdminCreatorPacksManager.tsx
+++ b/src/components/admin/AdminCreatorPacksManager.tsx
@@ -24,9 +24,12 @@ const AdminCreatorPacksManager = () => {
const loadPendingPacks = async () => {
setIsLoading(true);
- const packs = await fetchPendingPacks();
- setPendingPacks(packs);
- setIsLoading(false);
+ try {
+ const packs = await fetchPendingPacks();
+ setPendingPacks(packs);
+ } finally {
+ setIsLoading(false);
+ }
};
useEffect(() => {
diff --git a/src/components/admin/BlogEditor.tsx b/src/components/admin/BlogEditor.tsx
index e2bf097..925f5a6 100644
--- a/src/components/admin/BlogEditor.tsx
+++ b/src/components/admin/BlogEditor.tsx
@@ -37,15 +37,22 @@ export default function BlogEditor() {
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]);
diff --git a/src/components/profile/FontPicker.tsx b/src/components/profile/FontPicker.tsx
index 21da7cb..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: { name?: string; url?: string }) =>
- 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: 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 e35d0b2..d608169 100644
--- a/src/components/profile/ImageUpload.tsx
+++ b/src/components/profile/ImageUpload.tsx
@@ -71,7 +71,7 @@ export const ImageUpload: React.FC = ({
} 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 151a7ff..c68bd58 100644
--- a/src/components/profile/ProfileEditor.tsx
+++ b/src/components/profile/ProfileEditor.tsx
@@ -265,7 +265,7 @@ const ProfileEditor: React.FC = () => {
toast.success('Profile published successfully!');
setShowShare(true);
} catch (error: unknown) {
- toast.error(error?.message || 'Failed to save profile');
+ toast.error(error instanceof Error ? error.message : 'Failed to save profile');
console.error(error);
} finally {
setSaving(false);
diff --git a/src/components/profile/SvglPicker.tsx b/src/components/profile/SvglPicker.tsx
index 1933296..9dd5b5d 100644
--- a/src/components/profile/SvglPicker.tsx
+++ b/src/components/profile/SvglPicker.tsx
@@ -30,6 +30,23 @@ interface SvglPickerProps {
onChange: (url: string) => void;
}
+type SvglApiIcon = Record & {
+ id?: unknown;
+ title?: unknown;
+ category?: unknown;
+ route?: unknown;
+};
+
+const routeUrl = (route: unknown): string => {
+ if (typeof route === 'string') return route;
+ if (route && typeof route === 'object') {
+ const variants = route as { light?: unknown; dark?: unknown };
+ if (typeof variants.light === 'string') return variants.light;
+ if (typeof variants.dark === 'string') return variants.dark;
+ }
+ return '';
+};
+
export function SvglPicker({ value, onChange }: SvglPickerProps) {
const [open, setOpen] = useState(false);
const [icons, setIcons] = useState([]);
@@ -48,20 +65,16 @@ export function SvglPicker({ value, onChange }: SvglPickerProps) {
if (!res.ok) throw new Error('Failed to fetch icons');
const data = await res.json();
if (active) {
- const formattedIcons: SvglIcon[] = data.map((item: Record) => {
- const route = item.route;
- let url = '';
-
- if (typeof route === 'string') {
- url = route;
- } else if (typeof route === 'object' && route !== null) {
- // Prefer light variant or standard, fallback to dark
- url = route.light || route.dark || '';
- }
+ const formattedIcons: SvglIcon[] = (Array.isArray(data) ? data : []).flatMap((rawItem: unknown) => {
+ if (!rawItem || typeof rawItem !== 'object') return [];
+ const item = rawItem as SvglApiIcon;
+ const url = routeUrl(item.route);
+ if (typeof item.id !== 'number' || typeof item.title !== 'string' || typeof item.category !== 'string' || !url) return [];
+ let normalizedUrl = url;
// Ensure absolute URL if it is a relative path (though new API seems to return full URLs)
- if (url.startsWith('/')) {
- url = `https://svgl.app${url}`;
+ if (normalizedUrl.startsWith('/')) {
+ normalizedUrl = `https://svgl.app${normalizedUrl}`;
}
// CHANGE: Svgl.app does not support CORS for mask-image usage.
@@ -69,19 +82,23 @@ export function SvglPicker({ value, onChange }: SvglPickerProps) {
// Original: https://svgl.app/library/github_light.svg
// Target: https://cdn.jsdelivr.net/gh/pheralb/svgl@main/static/library/github_light.svg
- if (url.includes('svgl.app/library/')) {
- const filename = url.split('svgl.app/library/')[1];
- url = `https://cdn.jsdelivr.net/gh/pheralb/svgl@main/static/library/${filename}`;
- } else if (url.includes('/svg/')) {
+ if (normalizedUrl.includes('svgl.app/library/')) {
+ const filename = normalizedUrl.split('svgl.app/library/')[1];
+ normalizedUrl = `https://cdn.jsdelivr.net/gh/pheralb/svgl@main/static/library/${filename}`;
+ } else if (normalizedUrl.includes('/svg/')) {
// Old API format? Try to adapt or leave as is if not matching library pattern
// But usually the API returns /library/ path now.
- const part = url.split('/').pop();
- if (part) url = `https://cdn.jsdelivr.net/gh/pheralb/svgl@main/static/library/${part}`;
+ const part = normalizedUrl.split('/').pop();
+ if (part) normalizedUrl = `https://cdn.jsdelivr.net/gh/pheralb/svgl@main/static/library/${part}`;
}
return {
- ...item,
- fullUrl: url
+ id: item.id,
+ title: item.title,
+ category: item.category,
+ route: url,
+ url,
+ fullUrl: normalizedUrl,
};
});
setIcons(formattedIcons);
diff --git a/src/components/resources/ResourceAnnouncementBanner.tsx b/src/components/resources/ResourceAnnouncementBanner.tsx
index 6b2ddcc..8845d63 100644
--- a/src/components/resources/ResourceAnnouncementBanner.tsx
+++ b/src/components/resources/ResourceAnnouncementBanner.tsx
@@ -2,12 +2,15 @@ import { useState } from "react";
import { IconArrowRight, IconX } from "@tabler/icons-react";
const DISMISSED_KEY = "resources-announcement-dismissed-v1";
+const readDismissed = () => {
+ try { return localStorage.getItem(DISMISSED_KEY) === "true"; } catch { return false; }
+};
-export default function ResourceAnnouncementBanner() {
- const [visible, setVisible] = useState(() => localStorage.getItem(DISMISSED_KEY) !== "true");
+export default function ResourceAnnouncementBanner({ onExplore }: { onExplore: () => void }) {
+ const [visible, setVisible] = useState(() => !readDismissed());
if (!visible) return null;
const dismiss = () => {
- localStorage.setItem(DISMISSED_KEY, "true");
+ try { localStorage.setItem(DISMISSED_KEY, "true"); } catch { /* Best-effort persistence. */ }
setVisible(false);
};
return ;
}
diff --git a/src/components/resources/ResourceCard.tsx b/src/components/resources/ResourceCard.tsx
index 0fd3e06..a6a33ea 100644
--- a/src/components/resources/ResourceCard.tsx
+++ b/src/components/resources/ResourceCard.tsx
@@ -67,8 +67,10 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
}, []);
useEffect(() => {
+ let active = true;
+ setIsFontLoaded(false);
if (resource.category !== "fonts" || !resource.download_url) {
- return;
+ return () => { active = false; };
}
const fontUrl = resource.download_url;
@@ -76,13 +78,13 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
const styleId = `font-preview-${resource.id}-${btoa(fontName + fontUrl).slice(0, 12)}`;
if (document.fonts.check(`1em "${fontName}"`)) {
- setIsFontLoaded(true);
+ if (active) setIsFontLoaded(true);
return;
}
const maybeLoadFont = () => {
if (document.fonts.check(`1em "${fontName}"`)) {
- setIsFontLoaded(true);
+ if (active) setIsFontLoaded(true);
return;
}
@@ -95,9 +97,9 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
}
document.fonts.load(`1em "${fontName}"`).then(() => {
- setIsFontLoaded(true);
+ if (active) setIsFontLoaded(true);
}).catch(() => {
- setTimeout(() => setIsFontLoaded(true), 3000);
+ setTimeout(() => { if (active) setIsFontLoaded(true); }, 3000);
});
};
@@ -105,8 +107,9 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
maybeLoadFont();
} else {
const timer = setTimeout(maybeLoadFont, 2000);
- return () => clearTimeout(timer);
+ return () => { active = false; clearTimeout(timer); };
}
+ return () => { active = false; };
}, [resource.id, resource.download_url, resource.category, resource.title, isInView]);
const handlePreviewClick = (e: React.MouseEvent) => {
diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts
index bd28937..872af3d 100644
--- a/src/hooks/useProfile.ts
+++ b/src/hooks/useProfile.ts
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from './useAuth';
import { toast } from 'sonner';
+import type { Database } from '@/integrations/supabase/types';
interface UserProfile {
id: string;
@@ -90,7 +91,7 @@ export const useProfile = () => {
// default return=representation needs SELECT on every column (incl. email).
const { error } = await supabase
.from("profiles")
- .update(payload as never)
+ .update(payload)
.eq("id", user.id)
.select('id');
diff --git a/src/hooks/useUserFavorites.ts b/src/hooks/useUserFavorites.ts
index f702975..98f38d7 100644
--- a/src/hooks/useUserFavorites.ts
+++ b/src/hooks/useUserFavorites.ts
@@ -163,7 +163,6 @@ export const useUserFavorites = () => {
const { error } = await supabase
.from('user_favorites')
- // @ts-expect-error -- folder_id column exists in DB but generated types are stale
.update({ folder_id: folderId })
.eq('user_id', user.id)
.eq('resource_url', resourceUrl);
diff --git a/src/integrations/supabase/types.d.ts b/src/integrations/supabase/types.d.ts
new file mode 100644
index 0000000..dc5d697
--- /dev/null
+++ b/src/integrations/supabase/types.d.ts
@@ -0,0 +1,12 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+type Json = any;
+export type Database = {
+ public: {
+ Tables: any;
+ Views: any;
+ Functions: any;
+ Enums: any;
+ CompositeTypes: any;
+ };
+};
diff --git a/src/lib/showcases.ts b/src/lib/showcases.ts
index 113e05e..5b614b1 100644
--- a/src/lib/showcases.ts
+++ b/src/lib/showcases.ts
@@ -123,7 +123,8 @@ export async function getShowcasesWithProfiles(search?: string): Promise>)) {
+ type ProfileRow = { id: string; display_name?: string | null; avatar_url?: string | null; username?: string | null };
+ for (const row of (data as ProfileRow[])) {
if (row.id) profilesMap[row.id] = row;
}
}
diff --git a/src/pages/BackgroundGenerator.tsx b/src/pages/BackgroundGenerator.tsx
index 600ca26..dacab3d 100644
--- a/src/pages/BackgroundGenerator.tsx
+++ b/src/pages/BackgroundGenerator.tsx
@@ -24,6 +24,13 @@ import { Helmet } from "react-helmet-async";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+type Texture = { id: number | string; url: string; title: string; subcategory?: string };
+const isTexture = (value: unknown): value is Texture => {
+ if (!value || typeof value !== 'object') return false;
+ const texture = value as Record;
+ return (typeof texture.id === 'number' || typeof texture.id === 'string') && typeof texture.url === 'string' && typeof texture.title === 'string' && texture.subcategory === 'textures';
+};
+
const BackgroundGenerator = () => {
const [color, setColor] = useState("#9b87f5");
const [size, setSize] = useState("1920x1080");
@@ -34,7 +41,7 @@ const BackgroundGenerator = () => {
const [isGenerating, setIsGenerating] = useState(false);
const [generatedImage, setGeneratedImage] = useState(null);
const [uploadedImage, setUploadedImage] = useState(null);
- const [textures, setTextures] = useState>>([]);
+ const [textures, setTextures] = useState([]);
const [visibleTexturesCount, setVisibleTexturesCount] = useState(40);
const [selectedTexture, setSelectedTexture] = useState(null);
const [isLoadingTextures, setIsLoadingTextures] = useState(true);
@@ -49,7 +56,7 @@ const BackgroundGenerator = () => {
if (!response.ok) throw new Error('Failed to fetch textures');
const data = await response.json();
if (data && data.files) {
- const filteredTextures = data.files.filter((f: { subcategory?: string }) => f.subcategory === 'textures');
+ const filteredTextures = Array.isArray(data.files) ? data.files.filter(isTexture) : [];
setTextures(filteredTextures);
}
} catch (error) {
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index 7bee528..4161d50 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -26,6 +26,13 @@ type ProfileData = {
verified: boolean | null;
};
+type SelectedProfile = Omit & {
+ social_links: unknown;
+};
+
+const isSocialLinks = (value: unknown): value is Record =>
+ value === null || (typeof value === 'object' && Object.values(value).every((item) => typeof item === 'string'));
+
const SocialIcon = ({ type, url }: { type: string, url: string }) => {
const iconProps = { className: "w-6 h-6 hover:scale-110 transition-transform" };
switch (type) {
@@ -58,7 +65,12 @@ const ProfilePage: React.FC = () => {
if (error) throw error;
if (active) {
- setProfile(data as unknown as ProfileData); // Supabase JSON columns need runtime validation.
+ const row = data as SelectedProfile | null;
+ if (row && typeof row.id === 'string' && isSocialLinks(row.social_links)) {
+ setProfile({ ...row, email: null, social_links: row.social_links });
+ } else {
+ setProfile(null);
+ }
}
} catch (err) {
console.error(err);
diff --git a/src/pages/ResourcesHub.tsx b/src/pages/ResourcesHub.tsx
index 999591b..c345f7a 100644
--- a/src/pages/ResourcesHub.tsx
+++ b/src/pages/ResourcesHub.tsx
@@ -382,7 +382,7 @@ const ResourcesHub = () => {
-
+ { handleCategoryChange('music'); window.scrollTo({ top: 0, behavior: 'smooth' }); }} />
{
toast({
variant: "destructive",
title: "Error",
- description: error.message || "Failed to create showcase. Please try again.",
+ description: error instanceof Error && error.message
+ ? error.message
+ : "Failed to create showcase. Please try again.",
});
} finally {
setSubmitting(false);