@tanstack/cta-ui 0.10.0-alpha.26 → 0.10.0-alpha.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -0
  2. package/dist/assets/index-BeVre1U8.js +213 -0
  3. package/dist/assets/index-BeVre1U8.js.map +1 -0
  4. package/dist/assets/index-Co-Y5wb2.css +1 -0
  5. package/dist/index.html +9 -3
  6. package/dist/logo-color-100w.png +0 -0
  7. package/index.html +7 -1
  8. package/lib/engine-handling/add-to-app-wrapper.ts +39 -15
  9. package/lib/engine-handling/create-app-wrapper.ts +17 -6
  10. package/lib/engine-handling/file-helpers.ts +4 -2
  11. package/lib/engine-handling/generate-initial-payload.ts +58 -51
  12. package/lib/index.ts +11 -1
  13. package/lib/types.d.ts +18 -11
  14. package/lib-dist/engine-handling/add-to-app-wrapper.d.ts +2 -0
  15. package/lib-dist/engine-handling/add-to-app-wrapper.js +14 -9
  16. package/lib-dist/engine-handling/create-app-wrapper.d.ts +2 -1
  17. package/lib-dist/engine-handling/create-app-wrapper.js +6 -4
  18. package/lib-dist/engine-handling/file-helpers.js +3 -2
  19. package/lib-dist/engine-handling/generate-initial-payload.d.ts +14 -22
  20. package/lib-dist/engine-handling/generate-initial-payload.js +44 -49
  21. package/lib-dist/index.d.ts +2 -0
  22. package/lib-dist/index.js +6 -1
  23. package/package.json +6 -4
  24. package/public/logo-color-100w.png +0 -0
  25. package/src/components/background-animation.tsx +229 -0
  26. package/src/components/cta-sidebar.tsx +28 -33
  27. package/src/components/file-navigator.tsx +72 -74
  28. package/src/components/header.tsx +31 -0
  29. package/src/components/sidebar-items/add-ons.tsx +48 -45
  30. package/src/components/sidebar-items/mode-selector.tsx +6 -4
  31. package/src/components/sidebar-items/project-name.tsx +4 -5
  32. package/src/components/sidebar-items/typescript-switch.tsx +3 -3
  33. package/src/components/startup-dialog.tsx +4 -6
  34. package/src/components/ui/switch.tsx +6 -6
  35. package/src/hooks/use-mounted.ts +9 -0
  36. package/src/hooks/use-preferred-reduced-motion.ts +27 -0
  37. package/src/index.tsx +19 -20
  38. package/src/store/project.ts +36 -20
  39. package/src/styles.css +90 -18
  40. package/src/types.d.ts +1 -1
  41. package/tailwind.config.cjs +47 -0
  42. package/dist/assets/index-D0-fpgzI.js +0 -223
  43. package/dist/assets/index-D0-fpgzI.js.map +0 -1
  44. package/dist/assets/index-D5brMzJg.css +0 -1
@@ -1,7 +1,9 @@
1
+ import type { Environment } from '@tanstack/cta-engine';
1
2
  import type { Response } from 'express';
2
3
  export declare function addToAppWrapper(addOns: Array<string>, opts: {
3
4
  dryRun?: boolean;
4
5
  response?: Response;
6
+ environmentFactory?: () => Environment;
5
7
  }): Promise<{
6
8
  files: Record<string, string>;
7
9
  deletedFiles: Array<string>;
@@ -1,26 +1,29 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import { resolve } from 'node:path';
3
- import { addToApp, createDefaultEnvironment, createMemoryEnvironment, recursivelyGatherFiles, } from '@tanstack/cta-engine';
2
+ import { CONFIG_FILE, addToApp, createAppOptionsFromPersisted, createDefaultEnvironment, createMemoryEnvironment, createSerializedOptionsFromPersisted, readConfigFile, recursivelyGatherFiles, writeConfigFileToEnvironment, } from '@tanstack/cta-engine';
4
3
  import { cleanUpFileArray, cleanUpFiles } from './file-helpers.js';
5
4
  import { getProjectPath } from './server-environment.js';
6
5
  import { createAppWrapper } from './create-app-wrapper.js';
7
6
  export async function addToAppWrapper(addOns, opts) {
8
7
  const projectPath = getProjectPath();
9
- const persistedOptions = JSON.parse(readFileSync(resolve(projectPath, '.cta.json')).toString());
10
- persistedOptions.targetDir = projectPath;
8
+ const persistedOptions = await readConfigFile(projectPath);
9
+ if (!persistedOptions) {
10
+ throw new Error('No config file found');
11
+ }
12
+ const options = await createAppOptionsFromPersisted(persistedOptions);
13
+ options.targetDir = projectPath;
11
14
  const newAddons = [];
12
15
  for (const addOn of addOns) {
13
- if (!persistedOptions.existingAddOns.includes(addOn)) {
16
+ if (!options.chosenAddOns.some((a) => a.id === addOn)) {
14
17
  newAddons.push(addOn);
15
18
  }
16
19
  }
17
20
  if (newAddons.length === 0) {
18
- return await createAppWrapper(persistedOptions, opts);
21
+ const serializedOptions = createSerializedOptionsFromPersisted(persistedOptions);
22
+ return await createAppWrapper(serializedOptions, opts);
19
23
  }
20
24
  async function createEnvironment() {
21
25
  if (opts.dryRun) {
22
26
  const { environment, output } = createMemoryEnvironment(projectPath);
23
- environment.writeFile(resolve(projectPath, '.cta.json'), JSON.stringify(persistedOptions, null, 2));
24
27
  const localFiles = await cleanUpFiles(await recursivelyGatherFiles(projectPath, false));
25
28
  for (const file of Object.keys(localFiles)) {
26
29
  environment.writeFile(resolve(projectPath, file), localFiles[file]);
@@ -28,7 +31,7 @@ export async function addToAppWrapper(addOns, opts) {
28
31
  return { environment, output };
29
32
  }
30
33
  return {
31
- environment: createDefaultEnvironment(),
34
+ environment: opts.environmentFactory?.() ?? createDefaultEnvironment(),
32
35
  output: { files: {}, deletedFiles: [], commands: [] },
33
36
  };
34
37
  }
@@ -38,7 +41,7 @@ export async function addToAppWrapper(addOns, opts) {
38
41
  'Content-Type': 'text/plain',
39
42
  'Transfer-Encoding': 'chunked',
40
43
  });
41
- environment.startStep = ({ id, type, message }) => {
44
+ environment.startStep = ({ id, type, message, }) => {
42
45
  opts.response.write(JSON.stringify({
43
46
  msgType: 'start',
44
47
  id,
@@ -62,9 +65,11 @@ export async function addToAppWrapper(addOns, opts) {
62
65
  }
63
66
  else {
64
67
  environment.startRun();
68
+ environment.writeFile(resolve(projectPath, CONFIG_FILE), JSON.stringify(persistedOptions, null, 2));
65
69
  await addToApp(environment, newAddons, projectPath, {
66
70
  forced: true,
67
71
  });
72
+ writeConfigFileToEnvironment(environment, options);
68
73
  environment.finishRun();
69
74
  output.files = cleanUpFiles(output.files, projectPath);
70
75
  output.deletedFiles = cleanUpFileArray(output.deletedFiles, projectPath);
@@ -1,8 +1,9 @@
1
- import type { SerializedOptions } from '@tanstack/cta-engine';
1
+ import type { Environment, SerializedOptions } from '@tanstack/cta-engine';
2
2
  import type { Response } from 'express';
3
3
  export declare function createAppWrapper(projectOptions: SerializedOptions, opts: {
4
4
  dryRun?: boolean;
5
5
  response?: Response;
6
+ environmentFactory?: () => Environment;
6
7
  }): Promise<{
7
8
  files: Record<string, string>;
8
9
  deletedFiles: Array<string>;
@@ -7,11 +7,13 @@ export async function createAppWrapper(projectOptions, opts) {
7
7
  registerFrameworks();
8
8
  const framework = getFrameworkById(projectOptions.framework);
9
9
  let starter;
10
- const addOns = [...(projectOptions.chosenAddOns || [])];
10
+ const addOns = [...projectOptions.chosenAddOns];
11
11
  if (projectOptions.starter) {
12
12
  starter = await loadStarter(projectOptions.starter);
13
- for (const addOn of starter?.dependsOn ?? []) {
14
- addOns.push(addOn);
13
+ if (starter) {
14
+ for (const addOn of starter.dependsOn ?? []) {
15
+ addOns.push(addOn);
16
+ }
15
17
  }
16
18
  }
17
19
  const chosenAddOns = await finalizeAddOns(framework, projectOptions.mode, addOns);
@@ -31,7 +33,7 @@ export async function createAppWrapper(projectOptions, opts) {
31
33
  return createMemoryEnvironment(targetDir);
32
34
  }
33
35
  return {
34
- environment: createDefaultEnvironment(),
36
+ environment: opts.environmentFactory?.() ?? createDefaultEnvironment(),
35
37
  output: { files: {}, deletedFiles: [], commands: [] },
36
38
  };
37
39
  }
@@ -1,10 +1,11 @@
1
1
  import { basename } from 'node:path';
2
+ import { CONFIG_FILE } from '@tanstack/cta-engine';
2
3
  export function cleanUpFiles(files, targetDir) {
3
4
  return Object.keys(files).reduce((acc, file) => {
4
5
  const content = files[file].startsWith('base64::')
5
6
  ? '<binary file>'
6
7
  : files[file];
7
- if (basename(file) !== '.cta.json') {
8
+ if (basename(file) !== CONFIG_FILE) {
8
9
  acc[targetDir ? file.replace(targetDir, '.') : file] = content;
9
10
  }
10
11
  return acc;
@@ -12,7 +13,7 @@ export function cleanUpFiles(files, targetDir) {
12
13
  }
13
14
  export function cleanUpFileArray(files, targetDir) {
14
15
  return files.reduce((acc, file) => {
15
- if (basename(file) !== '.cta.json') {
16
+ if (basename(file) !== CONFIG_FILE) {
16
17
  acc.push(targetDir ? file.replace(targetDir, '.') : file);
17
18
  }
18
19
  return acc;
@@ -1,29 +1,11 @@
1
1
  import type { SerializedOptions } from '@tanstack/cta-engine';
2
- import type { Registry } from '../types.js';
2
+ import type { AddOnInfo } from '../types.js';
3
3
  export declare function generateInitialPayload(): Promise<{
4
4
  applicationMode: "add" | "setup";
5
5
  localFiles: Record<string, string>;
6
6
  addOns: {
7
- 'code-router': {
8
- id: string;
9
- name: string;
10
- description: string;
11
- type: "starter" | "add-on" | "example" | "toolchain";
12
- smallLogo: string | undefined;
13
- logo: string | undefined;
14
- link: string | undefined;
15
- dependsOn: string[] | undefined;
16
- }[];
17
- 'file-router': {
18
- id: string;
19
- name: string;
20
- description: string;
21
- type: "starter" | "add-on" | "example" | "toolchain";
22
- smallLogo: string | undefined;
23
- logo: string | undefined;
24
- link: string | undefined;
25
- dependsOn: string[] | undefined;
26
- }[];
7
+ 'code-router': AddOnInfo[];
8
+ 'file-router': AddOnInfo[];
27
9
  };
28
10
  options: SerializedOptions;
29
11
  output: {
@@ -36,5 +18,15 @@ export declare function generateInitialPayload(): Promise<{
36
18
  } | undefined;
37
19
  forcedRouterMode: import("@tanstack/cta-engine").Mode | undefined;
38
20
  forcedAddOns: string[];
39
- registry: Registry | undefined;
21
+ registry: {
22
+ "add-ons": never[];
23
+ starters: {
24
+ name: string;
25
+ description: string;
26
+ url: string;
27
+ banner?: string;
28
+ mode: Mode;
29
+ framework: string;
30
+ }[];
31
+ };
40
32
  }>;
@@ -1,16 +1,21 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import { basename, resolve } from 'node:path';
3
- import { createSerializedOptionsFromPersisted, getAllAddOns, getFrameworkById, recursivelyGatherFiles, } from '@tanstack/cta-engine';
2
+ import { createSerializedOptionsFromPersisted, getAllAddOns, getFrameworkById, getRawRegistry, getRegistryAddOns, readConfigFile, recursivelyGatherFiles, } from '@tanstack/cta-engine';
4
3
  import { cleanUpFiles } from './file-helpers.js';
5
4
  import { createAppWrapper } from './create-app-wrapper.js';
6
5
  import { registerFrameworks } from './framework-registration.js';
7
- import { getApplicationMode, getForcedAddOns, getForcedRouterMode, getProjectOptions, getProjectPath, getRegistry, } from './server-environment.js';
8
- function absolutizeUrl(originalUrl, relativeUrl) {
9
- if (relativeUrl.startsWith('http') || relativeUrl.startsWith('https')) {
10
- return relativeUrl;
11
- }
12
- const baseUrl = originalUrl.replace(/registry.json$/, '');
13
- return `${baseUrl}${relativeUrl.replace(/^\.\//, '')}`;
6
+ import { getApplicationMode, getForcedAddOns, getForcedRouterMode, getProjectOptions, getProjectPath, getRegistry as getRegistryURL, } from './server-environment.js';
7
+ function convertAddOnToAddOnInfo(addOn) {
8
+ return {
9
+ id: addOn.id,
10
+ name: addOn.name,
11
+ description: addOn.description,
12
+ modes: addOn.modes,
13
+ type: addOn.type,
14
+ smallLogo: addOn.smallLogo,
15
+ logo: addOn.logo,
16
+ link: addOn.link,
17
+ dependsOn: addOn.dependsOn,
18
+ };
14
19
  }
15
20
  export async function generateInitialPayload() {
16
21
  registerFrameworks();
@@ -20,7 +25,7 @@ export async function generateInitialPayload() {
20
25
  ? await cleanUpFiles(await recursivelyGatherFiles(projectPath, false))
21
26
  : {};
22
27
  const forcedRouterMode = getForcedRouterMode();
23
- function getSerializedOptions() {
28
+ async function getSerializedOptions() {
24
29
  if (applicationMode === 'setup') {
25
30
  const projectOptions = getProjectOptions();
26
31
  return {
@@ -31,63 +36,53 @@ export async function generateInitialPayload() {
31
36
  typescript: projectOptions.typescript || true,
32
37
  tailwind: projectOptions.tailwind || true,
33
38
  git: projectOptions.git || true,
39
+ targetDir: projectOptions.targetDir ||
40
+ resolve(projectPath, projectOptions.projectName),
34
41
  };
35
42
  }
36
43
  else {
37
- const persistedOptions = JSON.parse(readFileSync(resolve(projectPath, '.cta.json')).toString());
38
- return createSerializedOptionsFromPersisted(persistedOptions);
39
- }
40
- }
41
- const registryUrl = getRegistry();
42
- let registry;
43
- if (registryUrl) {
44
- registry = (await fetch(registryUrl).then((res) => res.json()));
45
- for (const addOn of registry['add-ons']) {
46
- addOn.url = absolutizeUrl(registryUrl, addOn.url);
47
- }
48
- for (const starter of registry.starters) {
49
- starter.url = absolutizeUrl(registryUrl, starter.url);
50
- if (starter.banner) {
51
- starter.banner = absolutizeUrl(registryUrl, starter.banner);
44
+ const persistedOptions = await readConfigFile(projectPath);
45
+ if (!persistedOptions) {
46
+ throw new Error('No config file found');
52
47
  }
48
+ return createSerializedOptionsFromPersisted(persistedOptions);
53
49
  }
54
50
  }
55
- const serializedOptions = getSerializedOptions();
51
+ const rawRegistry = await getRawRegistry(getRegistryURL());
52
+ const registryAddOns = await getRegistryAddOns(getRegistryURL());
53
+ const serializedOptions = await getSerializedOptions();
56
54
  const output = await createAppWrapper(serializedOptions, {
57
55
  dryRun: true,
58
56
  });
59
57
  const framework = await getFrameworkById(serializedOptions.framework);
60
- const codeRouter = getAllAddOns(framework, 'code-router').map((addOn) => ({
61
- id: addOn.id,
62
- name: addOn.name,
63
- description: addOn.description,
64
- type: addOn.type,
65
- smallLogo: addOn.smallLogo,
66
- logo: addOn.logo,
67
- link: addOn.link,
68
- dependsOn: addOn.dependsOn,
69
- }));
70
- const fileRouter = getAllAddOns(framework, 'file-router').map((addOn) => ({
71
- id: addOn.id,
72
- name: addOn.name,
73
- description: addOn.description,
74
- type: addOn.type,
75
- smallLogo: addOn.smallLogo,
76
- logo: addOn.logo,
77
- link: addOn.link,
78
- dependsOn: addOn.dependsOn,
79
- }));
58
+ const codeRouterAddOns = getAllAddOns(framework, 'code-router').map(convertAddOnToAddOnInfo);
59
+ const fileRouterAddOns = getAllAddOns(framework, 'file-router').map(convertAddOnToAddOnInfo);
60
+ for (const addOnInfo of registryAddOns || []) {
61
+ const addOnFramework = rawRegistry?.['add-ons'].find((addOn) => addOn.url === addOnInfo.id);
62
+ if (addOnFramework?.framework === serializedOptions.framework) {
63
+ if (addOnInfo.modes.includes('code-router')) {
64
+ codeRouterAddOns.push(convertAddOnToAddOnInfo(addOnInfo));
65
+ }
66
+ if (addOnInfo.modes.includes('file-router')) {
67
+ fileRouterAddOns.push(convertAddOnToAddOnInfo(addOnInfo));
68
+ }
69
+ }
70
+ }
71
+ const serializedRegistry = {
72
+ ['add-ons']: [],
73
+ starters: (rawRegistry?.starters || []).filter((starter) => starter.framework === serializedOptions.framework),
74
+ };
80
75
  return {
81
76
  applicationMode,
82
77
  localFiles,
83
78
  addOns: {
84
- 'code-router': codeRouter,
85
- 'file-router': fileRouter,
79
+ 'code-router': codeRouterAddOns,
80
+ 'file-router': fileRouterAddOns,
86
81
  },
87
82
  options: serializedOptions,
88
83
  output,
89
84
  forcedRouterMode,
90
85
  forcedAddOns: getForcedAddOns(),
91
- registry,
86
+ registry: serializedRegistry,
92
87
  };
93
88
  }
@@ -1,4 +1,6 @@
1
1
  import type { ServerEnvironment } from './engine-handling/server-environment.js';
2
+ import type { Environment } from '@tanstack/cta-engine';
2
3
  export declare function launchUI(options: Partial<ServerEnvironment> & {
3
4
  port?: number;
5
+ environmentFactory?: () => Environment;
4
6
  }): void;
package/lib-dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { dirname, resolve } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import express from 'express';
4
4
  import cors from 'cors';
5
+ import chalk from 'chalk';
5
6
  import { AddOnCompiledSchema, StarterCompiledSchema, } from '@tanstack/cta-engine';
6
7
  import { addToAppWrapper } from './engine-handling/add-to-app-wrapper.js';
7
8
  import { createAppWrapper } from './engine-handling/create-app-wrapper.js';
@@ -22,21 +23,25 @@ export function launchUI(options) {
22
23
  app.post('/api/add-to-app', async (req, res) => {
23
24
  await addToAppWrapper(req.body.addOns, {
24
25
  response: res,
26
+ environmentFactory: options.environmentFactory,
25
27
  });
26
28
  });
27
29
  app.post('/api/create-app', async (req, res) => {
28
30
  await createAppWrapper(req.body.options, {
29
31
  response: res,
32
+ environmentFactory: options.environmentFactory,
30
33
  });
31
34
  });
32
35
  app.post('/api/dry-run-add-to-app', async (req, res) => {
33
36
  res.send(await addToAppWrapper(req.body.addOns, {
34
37
  dryRun: true,
38
+ environmentFactory: options.environmentFactory,
35
39
  }));
36
40
  });
37
41
  app.post('/api/dry-run-create-app', async (req, res) => {
38
42
  res.send(await createAppWrapper(req.body.options, {
39
43
  dryRun: true,
44
+ environmentFactory: options.environmentFactory,
40
45
  }));
41
46
  });
42
47
  app.get('/api/initial-payload', async (_req, res) => {
@@ -119,6 +124,6 @@ export function launchUI(options) {
119
124
  });
120
125
  const port = requestedPort || process.env.PORT || 8080;
121
126
  app.listen(port, () => {
122
- console.log(`Create TanStack ${launchUI ? 'App' : 'API'} is running on http://localhost:${port}`);
127
+ console.log(`🔥 ${chalk.blueBright(`Create TanStack ${launchUI ? 'App' : 'API'}`)} is running on ${chalk.underline(`http://localhost:${port}`)}`);
123
128
  });
124
129
  }
package/package.json CHANGED
@@ -26,6 +26,7 @@
26
26
  "@tanstack/react-query-devtools": "^5.66.5",
27
27
  "@uiw/codemirror-theme-github": "^4.23.10",
28
28
  "@uiw/react-codemirror": "^4.23.10",
29
+ "chalk": "^5.4.1",
29
30
  "class-variance-authority": "^0.7.1",
30
31
  "clsx": "^2.1.1",
31
32
  "cors": "^2.8.5",
@@ -44,11 +45,12 @@
44
45
  "tailwindcss-animate": "^1.0.7",
45
46
  "vite-tsconfig-paths": "^5.1.4",
46
47
  "zustand": "^5.0.3",
47
- "@tanstack/cta-engine": "0.10.0-alpha.26",
48
- "@tanstack/cta-framework-solid": "0.10.0-alpha.26",
49
- "@tanstack/cta-framework-react-cra": "0.10.0-alpha.26"
48
+ "@tanstack/cta-engine": "0.10.0-alpha.27",
49
+ "@tanstack/cta-framework-react-cra": "0.10.0-alpha.27",
50
+ "@tanstack/cta-framework-solid": "0.10.0-alpha.27"
50
51
  },
51
52
  "devDependencies": {
53
+ "@tailwindcss/typography": "^0.5.16",
52
54
  "@testing-library/dom": "^10.4.0",
53
55
  "@testing-library/react": "^16.2.0",
54
56
  "@types/cors": "^2.8.17",
@@ -64,6 +66,6 @@
64
66
  "vitest": "^3.0.5",
65
67
  "web-vitals": "^4.2.4"
66
68
  },
67
- "version": "0.10.0-alpha.26",
69
+ "version": "0.10.0-alpha.28",
68
70
  "scripts": {}
69
71
  }
Binary file
@@ -0,0 +1,229 @@
1
+ import * as React from 'react'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ import { useMounted } from '@/hooks/use-mounted'
5
+ import { usePrefersReducedMotion } from '@/hooks/use-preferred-reduced-motion'
6
+
7
+ export function BackgroundAnimation() {
8
+ const canvasRef = React.useRef<HTMLCanvasElement>(null)
9
+ const prefersReducedMotion = usePrefersReducedMotion()
10
+ const mounted = useMounted()
11
+ const isHomePage = false
12
+
13
+ React.useEffect(() => {
14
+ if (prefersReducedMotion !== false) {
15
+ return
16
+ }
17
+
18
+ const canvas = canvasRef.current
19
+
20
+ let morphDuration = 2000
21
+ const waitDuration = 1000 * 60 * 2
22
+
23
+ const easingFn = cubicBezier(0.645, 0.045, 0.355, 1.0)
24
+
25
+ if (canvas) {
26
+ const ctx = canvas.getContext('2d')!
27
+
28
+ let rafId: ReturnType<typeof requestAnimationFrame> | null = null
29
+ let timeout: ReturnType<typeof setTimeout> | null = null
30
+ let startTime = performance.now()
31
+
32
+ function createBlobs() {
33
+ return shuffle([
34
+ {
35
+ color: { h: 10, s: 100, l: 50 },
36
+ },
37
+ {
38
+ color: { h: 40, s: 100, l: 50 },
39
+ },
40
+ {
41
+ color: { h: 150, s: 100, l: 50 },
42
+ },
43
+ {
44
+ color: { h: 200, s: 100, l: 50 },
45
+ },
46
+ ]).map((blob) => ({
47
+ ...blob,
48
+ x: Math.random() * canvas!.width,
49
+ y: Math.random() * canvas!.height,
50
+ r: Math.random() * 500 + 700,
51
+ colorH: blob.color.h,
52
+ colorS: blob.color.s,
53
+ colorL: blob.color.l,
54
+ }))
55
+ }
56
+
57
+ function shuffle<T>(array: T[]) {
58
+ for (let i = array.length - 1; i > 0; i--) {
59
+ const j = Math.floor(Math.random() * (i + 1))
60
+ ;[array[i], array[j]] = [array[j], array[i]]
61
+ }
62
+ return array
63
+ }
64
+
65
+ let startBlobs = createBlobs()
66
+ let currentBlobs = startBlobs
67
+ let targetBlobs: ReturnType<typeof createBlobs> = []
68
+
69
+ function resizeHandler() {
70
+ // Create an offscreen canvas and copy the current content
71
+ const offscreen = document.createElement('canvas')
72
+ offscreen.width = canvas!.width
73
+ offscreen.height = canvas!.height
74
+ offscreen.getContext('2d')!.drawImage(canvas!, 0, 0)
75
+
76
+ // Resize the main canvas
77
+ canvas!.width = window.innerWidth
78
+ canvas!.height = window.innerHeight
79
+
80
+ // Stretch and redraw the saved content to fill the new size
81
+ ctx.drawImage(offscreen, 0, 0, canvas!.width, canvas!.height)
82
+ }
83
+
84
+ function start() {
85
+ if (timeout) {
86
+ clearTimeout(timeout)
87
+ }
88
+ if (rafId) {
89
+ cancelAnimationFrame(rafId)
90
+ }
91
+
92
+ startBlobs = JSON.parse(JSON.stringify(currentBlobs))
93
+ targetBlobs = createBlobs()
94
+ startTime = performance.now()
95
+ animate()
96
+ }
97
+
98
+ function animate() {
99
+ ctx.clearRect(0, 0, canvas!.width, canvas!.height)
100
+
101
+ const time = performance.now() - startTime
102
+ const progress = time / morphDuration
103
+ const easedProgress = easingFn(progress)
104
+
105
+ // Draw the blobs
106
+ startBlobs.forEach((startBlob, i) => {
107
+ const targetBlob = targetBlobs[i]
108
+
109
+ currentBlobs[i].x = interpolate(
110
+ startBlob.x,
111
+ targetBlob.x,
112
+ easedProgress,
113
+ )
114
+ currentBlobs[i].y = interpolate(
115
+ startBlob.y,
116
+ targetBlob.y,
117
+ easedProgress,
118
+ )
119
+
120
+ const gradient = ctx.createRadialGradient(
121
+ currentBlobs[i].x,
122
+ currentBlobs[i].y,
123
+ 0,
124
+ currentBlobs[i].x,
125
+ currentBlobs[i].y,
126
+ currentBlobs[i].r,
127
+ )
128
+
129
+ currentBlobs[i].colorH = interpolate(
130
+ startBlob.colorH,
131
+ targetBlob.colorH,
132
+ easedProgress,
133
+ )
134
+ currentBlobs[i].colorS = interpolate(
135
+ startBlob.colorS,
136
+ targetBlob.colorS,
137
+ easedProgress,
138
+ )
139
+ currentBlobs[i].colorL = interpolate(
140
+ startBlob.colorL,
141
+ targetBlob.colorL,
142
+ easedProgress,
143
+ )
144
+
145
+ gradient.addColorStop(
146
+ 0,
147
+ `hsla(${currentBlobs[i].colorH}, ${currentBlobs[i].colorS}%, ${currentBlobs[i].colorL}%, 1)`,
148
+ )
149
+ gradient.addColorStop(
150
+ 1,
151
+ `hsla(${currentBlobs[i].colorH}, ${currentBlobs[i].colorS}%, ${currentBlobs[i].colorL}%, 0)`,
152
+ )
153
+
154
+ ctx.fillStyle = gradient
155
+ ctx.beginPath()
156
+ ctx.arc(
157
+ currentBlobs[i].x,
158
+ currentBlobs[i].y,
159
+ currentBlobs[i].r,
160
+ 0,
161
+ Math.PI * 2,
162
+ )
163
+ ctx.fill()
164
+ })
165
+
166
+ if (progress < 1) {
167
+ rafId = requestAnimationFrame(animate)
168
+ } else {
169
+ timeout = setTimeout(() => {
170
+ morphDuration = 4000
171
+ start()
172
+ }, waitDuration)
173
+ }
174
+ }
175
+
176
+ resizeHandler()
177
+ start()
178
+ window.addEventListener('resize', resizeHandler)
179
+
180
+ return () => {
181
+ if (rafId) {
182
+ cancelAnimationFrame(rafId)
183
+ }
184
+ if (timeout) {
185
+ clearTimeout(timeout)
186
+ }
187
+ window.removeEventListener('resize', resizeHandler)
188
+ }
189
+ }
190
+ }, [prefersReducedMotion])
191
+
192
+ return (
193
+ <div
194
+ className={twMerge(
195
+ 'fixed inset-0 z-0 opacity-20 pointer-events-none',
196
+ 'transition-opacity duration-[2s] ease-linear',
197
+ '[&+*]:relative',
198
+ mounted
199
+ ? isHomePage
200
+ ? 'opacity-10 dark:opacity-20'
201
+ : 'opacity-10 dark:opacity-20'
202
+ : 'opacity-0',
203
+ )}
204
+ >
205
+ <canvas ref={canvasRef} />
206
+ </div>
207
+ )
208
+ }
209
+
210
+ function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number) {
211
+ return function (t: number) {
212
+ const cx = 3 * p1x
213
+ const bx = 3 * (p2x - p1x) - cx
214
+ const ax = 1 - cx - bx
215
+
216
+ const cy = 3 * p1y
217
+ const by = 3 * (p2y - p1y) - cy
218
+ const ay = 1 - cy - by
219
+
220
+ const x = ((ax * t + bx) * t + cx) * t
221
+ const y = ((ay * t + by) * t + cy) * t
222
+
223
+ return y
224
+ }
225
+ }
226
+
227
+ function interpolate(start: number, end: number, progress: number) {
228
+ return start + (end - start) * progress
229
+ }