mirror of
https://github.com/Xevion/grain.git
synced 2025-12-15 20:05:02 -06:00
feat: migrate from React to Preact, add Vitest testing, and optimize bundle
Major framework and tooling changes: - Migrate from React 19 to Preact 10 with preact-iso for routing - Replace @mantine/hooks with custom hooks (useBooleanToggle, useViewportSize) - Switch from @heroicons/react to lucide-preact for icons - Replace chance.js with random-js for RNG Testing infrastructure: - Add Vitest with @testing-library/preact and happy-dom - Set up test configuration and initial App tests - Add Vitest UI for interactive test running Build optimizations: - Add cssnano for CSS minification - Configure aggressive bundle optimizations in Vite - Update to Tailwind CSS 4.1.17 Project structure: - Consolidate entry point from main.tsx to index.tsx - Reorganize CSS location (styles/index.css → index.css) - Add ESLint with preact config - Update TypeScript configuration for Preact
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
||||
import { Sparkles, ShieldAlert } from "lucide-preact";
|
||||
|
||||
const Post = () => {
|
||||
return (
|
||||
@@ -23,7 +22,7 @@ const Post = () => {
|
||||
target="_blank"
|
||||
className="hover:text-yellow-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon className="h-4 inline mb-2.5 m-2 " />
|
||||
<Sparkles className="h-4 inline mb-2.5 m-2 " />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -47,24 +46,24 @@ const Post = () => {
|
||||
</p>
|
||||
<p>
|
||||
By using a SVG with a{" "}
|
||||
<pre className="inline"><feTurbulence></pre> filter inside,
|
||||
stacked upon several <pre className="inline">radial-gradient</pre>{" "}
|
||||
<code><feTurbulence></code> filter inside,
|
||||
stacked upon several <code>radial-gradient</code>{" "}
|
||||
background images, the same effect can be created. Since SVGs do not
|
||||
naturally repeat internally, the SVG itself must be generated in
|
||||
such a way that the noise always displays the same way.
|
||||
</p>
|
||||
<p>
|
||||
React comes in handy here, allowing composition of an SVG, and then
|
||||
conversion to a <pre className="inline">base64</pre> encoded string.
|
||||
As a <pre className="inline">base64</pre> image, it can be fed into
|
||||
the <pre className="inline">background</pre> CSS property, allowing
|
||||
conversion to a <code>base64</code> encoded string.
|
||||
As a <code>base64</code> image, it can be fed into
|
||||
the <code>background</code> CSS property, allowing
|
||||
dynamic SVG generation.
|
||||
</p>
|
||||
<div className="pt-3">
|
||||
<a href="https://github.com/Xevion/grain">
|
||||
<div className="inline text-white text-medium drop-shadow-lg rounded border-2 shadow-xl border-zinc-600/75 m-2 p-2 bg-linear-to-r from-red-500 via-orange-500 to-orange-700">
|
||||
In Progress
|
||||
<ShieldExclamationIcon className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
|
||||
<ShieldAlert className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { useViewportSize, useToggle } from "@mantine/hooks";
|
||||
import useBackground from "@/utils/useBackground";
|
||||
import Post from "@/components/Post";
|
||||
import { hydrate, prerender as ssr } from "preact-iso";
|
||||
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { useMemo, useState } from "react";
|
||||
import "./index.css";
|
||||
|
||||
function App() {
|
||||
import { useViewportSize } from "./utils/useViewportSize";
|
||||
import { useBooleanToggle } from "./utils/useBooleanToggle";
|
||||
import useBackground from "./utils/useBackground";
|
||||
import Post from "./components/Post";
|
||||
import { RefreshCw, Eye, EyeOff } from "lucide-preact";
|
||||
import { useMemo } from "preact/hooks";
|
||||
|
||||
export function App() {
|
||||
const { width, height } = useViewportSize();
|
||||
const { svg, backgrounds, regenerate } = useBackground({
|
||||
width,
|
||||
height,
|
||||
ratio: 0.4,
|
||||
});
|
||||
const [postHidden, toggleHidden] = useToggle([false, true]);
|
||||
|
||||
const [iconSpinning, toggleIconSpinning] = useToggle([false, true]);
|
||||
const [postHidden, toggleHidden] = useBooleanToggle(false);
|
||||
const [iconSpinning, toggleIconSpinning] = useBooleanToggle(false);
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
@@ -42,7 +41,7 @@ function App() {
|
||||
setTimeout(() => toggleIconSpinning(false), 200);
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
<RefreshCw
|
||||
className={`transition-transform duration-200 ${
|
||||
iconSpinning ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
@@ -52,14 +51,13 @@ function App() {
|
||||
className="block p-2 w-10 h-10 rounded mx-auto xs:mx-0 xs:ml-5 mt-5 shadow-inner-md bg-zinc-700 text-zinc-100 button"
|
||||
onClick={() => toggleHidden()}
|
||||
>
|
||||
{postHidden ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
{postHidden ? <Eye /> : <EyeOff />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`h-screen transition-opacity ease-in-out duration-75 ${
|
||||
postHidden ? "opacity-0 pointer-events-none" : ""
|
||||
} flex col-span-9 sm:col-span-6 md:col-span-5 w-full min-h-screen`}
|
||||
|
||||
>
|
||||
<div className="bg-white overflow-y-auto">
|
||||
<Post />
|
||||
@@ -71,4 +69,10 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
if (typeof window !== "undefined") {
|
||||
hydrate(<App />, document.getElementById("root"));
|
||||
}
|
||||
|
||||
export async function prerender(data: any) {
|
||||
return await ssr(<App {...data} />);
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "@/components/App";
|
||||
import "@/styles/index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
48
src/test/App.test.tsx
Normal file
48
src/test/App.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { h } from "preact";
|
||||
import { render } from "@testing-library/preact";
|
||||
import type { FunctionComponent } from "preact";
|
||||
|
||||
describe("App", () => {
|
||||
let App: FunctionComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock window dimensions for useViewportSize hook
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1920,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1080,
|
||||
});
|
||||
|
||||
// Mock btoa for useBackground hook
|
||||
global.btoa = vi.fn((str) => Buffer.from(str).toString("base64"));
|
||||
|
||||
// Dynamically import App after mocks are set up
|
||||
const module = await import("../index");
|
||||
App = module.App;
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
expect(() => render(h(App, {}))).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders the Post component with main heading", () => {
|
||||
const { getAllByText } = render(h(App, {}));
|
||||
expect(getAllByText("Grain").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders author information", () => {
|
||||
const { getAllByText } = render(h(App, {}));
|
||||
expect(getAllByText("Ryan Walters").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders with gradient class", () => {
|
||||
const { container } = render(h(App, {}));
|
||||
expect(container.innerHTML).toContain("gradient");
|
||||
});
|
||||
});
|
||||
2
src/test/setup.ts
Normal file
2
src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "preact/debug";
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Random } from "random-js";
|
||||
import { useMemo, useState } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { getEdgePoint } from "@/utils/helpers";
|
||||
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { getEdgePoint } from "./helpers";
|
||||
interface useBackgroundProps {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -27,11 +25,7 @@ const generateBackground = (): string[] => {
|
||||
.fill(null)
|
||||
.map(() => random.pick(palette))
|
||||
.map((color) => {
|
||||
const [x, y] = getEdgePoint(
|
||||
random.integer(0, 400),
|
||||
100,
|
||||
100
|
||||
);
|
||||
const [x, y] = getEdgePoint(random.integer(0, 400), 100, 100);
|
||||
return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`;
|
||||
});
|
||||
};
|
||||
@@ -47,35 +41,20 @@ const useBackground = ({
|
||||
setBackground(generateBackground());
|
||||
};
|
||||
|
||||
const noise = useMemo(() => {
|
||||
const noise = (): string => {
|
||||
const svgWidth = Math.ceil((width ?? 1920) * ratio);
|
||||
const svgHeight = Math.ceil((height ?? 1080) * ratio);
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="2.1"
|
||||
numOctaves="2"
|
||||
seed={random.integer(0, 1000000)}
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
<g opacity={0.9}>
|
||||
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}, [width, height, ratio]);
|
||||
const seed = random.integer(0, 1000000);
|
||||
|
||||
return `<svg viewBox="0 0 ${svgWidth} ${svgHeight}" xmlns="http://www.w3.org/2000/svg"><filter id="noiseFilter"><feTurbulence type="fractalNoise" baseFrequency="2.1" numOctaves="2" seed="${seed}" stitchTiles="stitch"/></filter><g opacity="0.9"><rect width="100%" height="100%" filter="url(#noiseFilter)"/></g></svg>`;
|
||||
};
|
||||
|
||||
return {
|
||||
regenerate,
|
||||
svg: `data:image/svg+xml;base64,${window.btoa(
|
||||
ReactDOMServer.renderToString(noise)
|
||||
)}`,
|
||||
svg:
|
||||
typeof window !== "undefined"
|
||||
? `data:image/svg+xml;base64,${window.btoa(noise())}`
|
||||
: "",
|
||||
backgrounds: background,
|
||||
};
|
||||
};
|
||||
|
||||
18
src/utils/useBooleanToggle.ts
Normal file
18
src/utils/useBooleanToggle.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
|
||||
export function useBooleanToggle(
|
||||
initialValue: boolean = false
|
||||
): [boolean, (nextValue?: boolean) => void] {
|
||||
const [value, setValue] = useState<boolean>(initialValue);
|
||||
|
||||
const toggle = useCallback((nextValue?: boolean) => {
|
||||
if (typeof nextValue === "boolean") {
|
||||
setValue(nextValue);
|
||||
return;
|
||||
}
|
||||
setValue((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return [value, toggle];
|
||||
}
|
||||
|
||||
24
src/utils/useViewportSize.ts
Normal file
24
src/utils/useViewportSize.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
export interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useViewportSize(): ViewportSize {
|
||||
const [size, setSize] = useState<ViewportSize>({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user