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 = () => { setThemeConfig({ ...themeConfig, buttonStyle: val })} + onValueChange={(val: string) => setThemeConfig({ ...themeConfig, buttonStyle: val as ProfileThemeConfig['buttonStyle'] })} > @@ -570,7 +572,7 @@ const ProfileEditor: React.FC = () => {