diff --git a/package.json b/package.json index 498a950..3447d71 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "dependencies": { "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", - "framer-motion": "^12.38.0", "lucide-react": "^1.11.0", "next": "14.2.35", "next-auth": "^4.24.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a19fa61..51183f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - framer-motion: - specifier: ^12.38.0 - version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^1.11.0 version: 1.14.0(react@18.3.1) @@ -1066,20 +1063,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - framer-motion@12.38.0: - resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1608,12 +1591,6 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - motion-dom@12.38.0: - resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} - - motion-utils@12.36.0: - resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3046,8 +3023,8 @@ snapshots: '@typescript-eslint/parser': 8.59.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -3066,7 +3043,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -3077,22 +3054,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -3103,7 +3080,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -3288,15 +3265,6 @@ snapshots: format@0.2.2: {} - framer-motion@12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - motion-dom: 12.38.0 - motion-utils: 12.36.0 - tslib: 2.8.1 - optionalDependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - fs.realpath@1.0.0: {} function-bind@1.1.2: {} @@ -4096,12 +4064,6 @@ snapshots: minipass@7.1.3: {} - motion-dom@12.38.0: - dependencies: - motion-utils: 12.36.0 - - motion-utils@12.36.0: {} - ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/src/app/dashboard/dev/page.tsx b/src/app/dashboard/dev/page.tsx index f6ef473..966300e 100644 --- a/src/app/dashboard/dev/page.tsx +++ b/src/app/dashboard/dev/page.tsx @@ -1558,7 +1558,9 @@ export default function DevDashboardPage() {
- - {open && ( - -
- {item.a} -
-
- )} -
+
+
+ {item.a} +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 0af4e35..7912c8f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -478,6 +478,17 @@ ol { color: var(--text-muted); } +/* ── Focus Indicators ── */ +.btn:focus-visible, +.input:focus-visible, +.card:focus-visible, +a:focus-visible, +button:focus-visible, +[role="button"]:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + /* ── Mobile Responsive ─────────────────────────────────────── */ @media (max-width: 768px) { @@ -912,6 +923,27 @@ ol { background: rgba(255, 255, 255, 0.2); } +/* ── Entry Animations ── */ +@keyframes fadeSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + /* ── Reduced Motion ── */ @media (prefers-reduced-motion: reduce) { *, diff --git a/src/app/page.tsx b/src/app/page.tsx index bba14aa..4cff412 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import HomeContent from "@/components/HomeContent"; +import LatestPluginsSection from "@/components/LatestPluginsSection"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -27,5 +28,10 @@ async function StatsSection() { } export default function Home() { - return ; + return ( + <> + + + + ); } diff --git a/src/app/plugins/[slug]/page.tsx b/src/app/plugins/[slug]/page.tsx index 2b81160..96eebc2 100644 --- a/src/app/plugins/[slug]/page.tsx +++ b/src/app/plugins/[slug]/page.tsx @@ -14,6 +14,7 @@ import { BadgeCheck, } from "lucide-react"; import dynamic from "next/dynamic"; +import { cache } from "react"; import Image from "next/image"; import PluginImage from "@/components/PluginImage"; import VersionSelector from "@/components/VersionSelector"; @@ -60,20 +61,68 @@ const PluginAnalyticsChart = dynamic( ); const DependencyGraph = dynamic(() => import("@/components/DependencyGraph"), { ssr: false, + loading: () => ( +
+

+ Loading dependency graph... +

+
+ ), }); const PluginRatings = dynamic(() => import("@/components/PluginRatings"), { ssr: false, + loading: () => ( +
+

+ Loading ratings... +

+
+ ), }); const VirusTotalCard = dynamic(() => import("@/components/VirusTotalCard"), { ssr: false, + loading: () => ( +
+

+ Loading security scan... +

+
+ ), }); -async function getPlugin(slug: string) { +const getPlugin = cache(async function getPlugin(slug: string) { const { data } = await fetchApi(`/api/v1/plugins/${slug}`, { revalidate: 30, }); return data?.data || null; -} +}); export async function generateMetadata({ params, @@ -1103,7 +1152,6 @@ export default async function PluginDetailPage({ {/* Dependency Graph */} - diff --git a/src/app/plugins/page.tsx b/src/app/plugins/page.tsx index f16d63e..abf5234 100644 --- a/src/app/plugins/page.tsx +++ b/src/app/plugins/page.tsx @@ -61,7 +61,7 @@ export default async function PluginsPage({ p.set("category", searchParams.category as string); if (searchParams.sort) p.set("sort", searchParams.sort as string); if (searchParams.type) p.set("type", searchParams.type as string); - return `/plugins?${p.toString()}`; + return `/plugins?${p.toString()}#plugin-grid`; } // Generate page numbers to display @@ -127,7 +127,7 @@ export default async function PluginsPage({ {/* Plugin Grid */} -
+
{/* Pagination Controls */} @@ -151,6 +151,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-card)", @@ -170,6 +171,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-secondary)", @@ -203,6 +205,10 @@ export default async function PluginsPage({ href={pageUrl(p)} style={{ padding: "0.5rem 0.75rem", + minHeight: "44px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", borderRadius: "var(--radius-md)", border: p === currentPage @@ -220,7 +226,7 @@ export default async function PluginsPage({ fontWeight: p === currentPage ? 700 : 500, textDecoration: "none", transition: "all 150ms", - minWidth: "38px", + minWidth: "44px", textAlign: "center", }} > @@ -238,6 +244,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-card)", @@ -257,6 +264,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-secondary)", diff --git a/src/components/FadeIn.tsx b/src/components/FadeIn.tsx index 35ddf93..cd0ff25 100644 --- a/src/components/FadeIn.tsx +++ b/src/components/FadeIn.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; -import type { ReactNode } from "react"; +import { useInView } from "@/hooks/useInView"; +import { useEffect, useState, type ReactNode } from "react"; interface FadeInProps { children: ReactNode; @@ -13,14 +13,26 @@ interface FadeInProps { style?: React.CSSProperties; } -const directionMap = { - up: { y: 20, x: 0 }, - down: { y: -20, x: 0 }, - left: { x: 20, y: 0 }, - right: { x: -20, y: 0 }, - none: { x: 0, y: 0 }, +const directionStyles = { + up: (d: number) => ({ transform: `translateY(${d}px)` }), + down: (d: number) => ({ transform: `translateY(-${d}px)` }), + left: (d: number) => ({ transform: `translateX(${d}px)` }), + right: (d: number) => ({ transform: `translateX(-${d}px)` }), + none: () => ({ transform: "scale(0.97)" }), }; +function usePrefersReducedMotion() { + const [prefersReduced, setPrefersReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReduced(mq.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + return prefersReduced; +} + export default function FadeIn({ children, delay = 0, @@ -30,37 +42,32 @@ export default function FadeIn({ className, style, }: FadeInProps) { - const prefersReducedMotion = useReducedMotion(); - const offset = directionMap[direction]; - const scale = direction === "none" ? 0.97 : 1; + const { ref, inView } = useInView(); + const prefersReducedMotion = usePrefersReducedMotion(); if (prefersReducedMotion) { return ( -
+
{children}
); } + const initialTransform = directionStyles[direction](distance); + return ( - 0 ? distance : -distance) : 0, - y: offset.y !== 0 ? (offset.y > 0 ? distance : -distance) : 0, - scale, - }} - whileInView={{ opacity: 1, x: 0, y: 0, scale: 1 }} - viewport={{ once: true, margin: "-40px" }} - transition={{ - duration, - delay, - ease: [0.25, 0.1, 0.25, 1], - }} +
{children} - +
); } diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index e8a2444..ac8e891 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -15,7 +15,6 @@ import { } from "lucide-react"; import Link from "next/link"; import AnimatedNumber from "@/components/AnimatedNumber"; -import LatestPluginsSection from "@/components/LatestPluginsSection"; import FadeIn from "@/components/FadeIn"; import StaggerContainer, { StaggerItem } from "@/components/StaggerContainer"; @@ -41,6 +40,17 @@ function TerminalMock() { const timerRef = useRef[]>([]); useEffect(() => { + if ( + typeof window !== "undefined" && + sessionStorage.getItem("terminal-seen") + ) { + setTypedChars(COMMAND_TEXT.length); + setVisibleLines(OUTPUT_LINES.length); + setShowCursor(false); + setShowPrompt(true); + return; + } + const t = timerRef.current; t.length = 0; @@ -64,6 +74,7 @@ function TerminalMock() { setTimeout(() => { setShowCursor(false); setShowPrompt(true); + sessionStorage.setItem("terminal-seen", "1"); }, offset), ); @@ -564,9 +575,6 @@ export default function HomeContent({ stats }: HomeContentProps) { - {/* ── Recent Releases ── */} - - {/* ── Features ── */}
([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - async function fetchPlugins() { - try { - const apiUrl = - process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; - const res = await fetch( - `${apiUrl}/api/v1/plugins/latest?page=1&pageSize=6`, - ); - const json = await res.json(); - if (json?.success && json?.data?.plugins) { - setPlugins(json.data.plugins); - } else { - setError(true); - } - } catch { - setError(true); - } finally { - setLoading(false); - } +export default async function LatestPluginsSection() { + let plugins: Plugin[] = []; + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; + const res = await fetch( + `${apiUrl}/api/v1/plugins/latest?page=1&pageSize=6`, + { next: { revalidate: 60 } }, + ); + const json = await res.json(); + if (json?.success && json?.data?.plugins) { + plugins = json.data.plugins; } - fetchPlugins(); - }, []); + } catch { + return null; + } + + if (plugins.length === 0) return null; - if (error && plugins.length === 0) return null; + const VERIFIED_ORGS = ["EndstoneMC", "two-tech-dev"]; return (
@@ -99,196 +85,189 @@ export default function LatestPluginsSection() {
- {!loading && ( - <> - - {plugins.map((plugin) => { - const avgRating = plugin.stars - ? Math.round((plugin.stars / 20) * 10) / 10 - : 0; - const repoOwner = - plugin.repoUrl?.match(/github\.com\/([^/]+)/)?.[1]; - const isVerified = repoOwner - ? VERIFIED_ORGS.includes(repoOwner) - : false; + + {plugins.map((plugin) => { + const avgRating = plugin.stars + ? Math.round((plugin.stars / 20) * 10) / 10 + : 0; + const repoOwner = plugin.repoUrl?.match(/github\.com\/([^/]+)/)?.[1]; + const isVerified = repoOwner + ? VERIFIED_ORGS.includes(repoOwner) + : false; - return ( - - + +
+
+ +
+ +
+

+ {plugin.displayName} + {isVerified && ( + + + + )} +

- -
- -
-

v{plugin.latestVersion || "1.0.0"} + - {plugin.displayName} - {isVerified && ( - - - + {plugin.repoUrl?.match(/github\.com\/([^/]+)/)?.[1] || + plugin.author?.displayName || + plugin.author?.username} + +

+
+ + {new Date(plugin.createdAt || "").toLocaleDateString( + "en-GB", + { + day: "2-digit", + month: "short", + year: "numeric", + }, )} - -
-
+ + {plugin.downloads?.toLocaleString() ?? 0} downloads + + {avgRating > 0 && ( + - v{plugin.latestVersion || "1.0.0"} + {"★".repeat(Math.round(avgRating))} + {"☆".repeat(5 - Math.round(avgRating))} - {plugin.repoUrl?.match( - /github\.com\/([^/]+)/, - )?.[1] || - plugin.author?.displayName || - plugin.author?.username} - -
-
- - {new Date( - plugin.createdAt || "", - ).toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - })} - - - {plugin.downloads?.toLocaleString() ?? 0}{" "} - downloads + ({avgRating}) - {avgRating > 0 && ( - - {"★".repeat(Math.round(avgRating))} - {"☆".repeat(5 - Math.round(avgRating))} - - ({avgRating}) - - - )} -
-
+
+ )}
- - - ); - })} - - - )} +
+
+ +
+ ); + })} +
); } diff --git a/src/components/LiveBuildLog.tsx b/src/components/LiveBuildLog.tsx index b5e8b3a..c98371f 100644 --- a/src/components/LiveBuildLog.tsx +++ b/src/components/LiveBuildLog.tsx @@ -93,8 +93,7 @@ export default function LiveBuildLog({ fontWeight = "600"; } else if (line.includes("->")) color = "#94a3b8"; // muted slate-400 for sub-steps - else if (line.includes("Security Scan")) - color = "#c084fc"; // purple for security + else if (line.includes("Security Scan")) color = "#c084fc"; // purple for security return (
- - {dropdownOpen && ( - -
- {menuItems} -
- {signOutButton} -
- - )} - +
+
+ {menuItems} +
+ {signOutButton} +
+
); } @@ -344,6 +340,7 @@ export default function NavbarClient({ borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-lg)", overflow: "hidden", + animation: "fadeIn 0.15s ease", }} >
([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); - useEffect(() => { - async function fetchAnalytics() { - try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/api/v1/plugins/${slug}/analytics`, + const fetchAnalytics = async () => { + setLoading(true); + setError(false); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/api/v1/plugins/${slug}/analytics`, + ); + const json = await res.json(); + if (json.success && json.data?.length > 0) { + setData( + json.data.map((d: any) => ({ + date: new Date(d.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + downloads: d.downloads, + views: d.views || 0, + })), ); - const json = await res.json(); - if (json.success && json.data?.length > 0) { - setData( - json.data.map((d: any) => ({ - date: new Date(d.date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }), - downloads: d.downloads, - views: d.views || 0, - })), - ); - } - } catch { - // No data available - } finally { - setLoading(false); } + } catch { + setError(true); + } finally { + setLoading(false); } + }; + + useEffect(() => { fetchAnalytics(); }, [slug]); @@ -81,8 +85,23 @@ export default function PluginAnalyticsChart({ slug }: { slug: string }) { style={{ margin: "0 auto var(--space-2)" }} />

- No download analytics available yet + {error + ? "Failed to load analytics data." + : "No download analytics available yet"}

+ {error && ( + + )}
); } diff --git a/src/components/PluginCardGrid.tsx b/src/components/PluginCardGrid.tsx index b7a4489..e2ccbb4 100644 --- a/src/components/PluginCardGrid.tsx +++ b/src/components/PluginCardGrid.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import Link from "next/link"; import { FlaskConical, BadgeCheck } from "lucide-react"; import PluginImage from "@/components/PluginImage"; @@ -42,7 +42,7 @@ export default function PluginCardGrid({ plugins }: { plugins: Plugin[] }) { : false; return ( -
)} -
+ ); })}
diff --git a/src/components/PluginImage.tsx b/src/components/PluginImage.tsx index 5b81806..2875cc1 100644 --- a/src/components/PluginImage.tsx +++ b/src/components/PluginImage.tsx @@ -1,5 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; +import Image from "next/image"; interface Props { iconUrl?: string | null; @@ -7,59 +8,38 @@ interface Props { alt: string; } -export default function PluginImage({ iconUrl, repoUrl, alt }: Props) { - const [imgSrc, setImgSrc] = useState(null); - - useEffect(() => { - let targetSrc = iconUrl; - if (!targetSrc && repoUrl) { - const repoPath = repoUrl - .replace("https://github.com/", "") - .replace(/\/$/, ""); - targetSrc = `https://raw.githubusercontent.com/${repoPath}/main/icon.png`; - } - - if (!targetSrc) { - setImgSrc("/logo.png"); - return; - } - - targetSrc = targetSrc.replace(/\/\.\//g, "/"); - - const img = new Image(); - img.onload = () => setImgSrc(targetSrc as string); - img.onerror = () => setImgSrc("/logo.png"); - img.src = targetSrc; - }, [iconUrl, repoUrl]); - - if (!imgSrc) { - return ( -
- ); +function getSrc(iconUrl?: string | null, repoUrl?: string | null): string { + if (iconUrl) return iconUrl.replace(/\/\.\//g, "/"); + if (repoUrl) { + const repoPath = repoUrl + .replace("https://github.com/", "") + .replace(/\/$/, ""); + return `https://raw.githubusercontent.com/${repoPath}/main/icon.png`; } + return "/logo.png"; +} - const isFallback = imgSrc === "/logo.png"; +export default function PluginImage({ iconUrl, repoUrl, alt }: Props) { + const [errored, setErrored] = useState(false); + const src = errored ? "/logo.png" : getSrc(iconUrl, repoUrl); + const isFallback = src === "/logo.png"; return ( - // eslint-disable-next-line @next/next/no-img-element - {isFallback setErrored(true)} style={{ width: "100%", height: "100%", borderRadius: "var(--radius-lg)", objectFit: "cover", padding: "0", - transition: "opacity 0.2s", }} /> ); diff --git a/src/components/PluginSearch.tsx b/src/components/PluginSearch.tsx index 6a5a28c..ff14fdb 100644 --- a/src/components/PluginSearch.tsx +++ b/src/components/PluginSearch.tsx @@ -18,7 +18,7 @@ export default function PluginSearch() { params.delete("page"); router.push(`?${params.toString()}`, { scroll: false }); } - }, 500); + }, 300); return () => clearTimeout(timeoutId); }, [search, router, searchParams]); diff --git a/src/components/StaggerContainer.tsx b/src/components/StaggerContainer.tsx index 89532ee..2864b85 100644 --- a/src/components/StaggerContainer.tsx +++ b/src/components/StaggerContainer.tsx @@ -1,7 +1,14 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; -import type { ReactNode } from "react"; +import { useInView } from "@/hooks/useInView"; +import { + Children, + cloneElement, + useEffect, + useState, + isValidElement, + type ReactNode, +} from "react"; interface StaggerContainerProps { children: ReactNode; @@ -10,40 +17,51 @@ interface StaggerContainerProps { style?: React.CSSProperties; } +function usePrefersReducedMotion() { + const [prefersReduced, setPrefersReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReduced(mq.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + return prefersReduced; +} + export default function StaggerContainer({ children, staggerDelay = 0.08, className, style, }: StaggerContainerProps) { - const prefersReducedMotion = useReducedMotion(); + const { ref, inView } = useInView(); + const prefersReducedMotion = usePrefersReducedMotion(); if (prefersReducedMotion) { return ( -
+
{children}
); } - return ( - { + if (!isValidElement(child)) return child; + return cloneElement(child as React.ReactElement, { + style: { + ...(child.props.style || {}), + animation: `fadeSlideUp 0.45s cubic-bezier(0.25, 0.1, 0.25, 1) ${i * staggerDelay}s both`, }, - }, - }} - className={className} - style={style} - > - {children} - + }); + }) + : null; + + return ( +
+ {indexedChildren} +
); } @@ -56,34 +74,9 @@ export function StaggerItem({ className?: string; style?: React.CSSProperties; }) { - const prefersReducedMotion = useReducedMotion(); - - if (prefersReducedMotion) { - return ( -
- {children} -
- ); - } - return ( - +
{children} - +
); } diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts new file mode 100644 index 0000000..dd96c59 --- /dev/null +++ b/src/hooks/useInView.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export function useInView(options?: IntersectionObserverInit) { + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.disconnect(); + } + }, + { threshold: 0, rootMargin: "-40px", ...options }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [options]); + + return { ref, inView }; +}