diff --git a/README.md b/README.md index 869fb031..f31c3855 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,19 @@ In order to get started with developing Lapse, run these commands: # Install all packages pnpm install -# Set up the development environment +# Set up the development environment (makes the .env file for you) pnpm dev:setup-env # Start the web client and backend pnpm dev ``` +You'll need to install ffmpeg as well. + +- Windows: install Chocolatey and then run `choco install ffmpeg` in an Administrative command prompt +- Mac: installed homebrew and then run `brew install ffmpeg` +- Linux: install ffmpeg with your package manager + To start and stop the development environment, use `pnpm dev:start-env` and `pnpm dev:stop-env` respectively. When developing, it's a good idea to re-compile all packages on the fly! diff --git a/apps/client/package.json b/apps/client/package.json index f0fca305..660b6f63 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -36,6 +36,7 @@ "pretty-bytes": "^7.1.0", "react": "19.2.3", "react-dom": "19.2.3", + "react-drag-drop-files": "^3.1.0", "tus-js-client": "^4.3.1", "valibot": "^1.2.0", "webpack": "^5.104.1" diff --git a/apps/client/src/components/layout/Header.tsx b/apps/client/src/components/layout/Header.tsx index 878e7387..9ffccc33 100644 --- a/apps/client/src/components/layout/Header.tsx +++ b/apps/client/src/components/layout/Header.tsx @@ -2,7 +2,16 @@ import NextLink from "next/link"; import { useRouter } from "next/router"; import Icon from "@hackclub/icons"; import { useCallback, useState } from "react"; +import { useAsyncEffect } from "@/hooks/useAsyncEffect"; import clsx from "clsx"; +import * as mediabunny from "mediabunny"; +import { SteppedProgress } from "@/common"; +import { videoGenerateThumbnail } from "@/video"; +import { getCurrentDevice } from "@/encryption"; +import { encryptData, fromHex } from "@hackclub/lapse-shared"; +import prettyBytes from "pretty-bytes"; +import { api, apiUpload } from "@/api"; +import posthog from "posthog-js"; import { Button } from "@/components/ui/Button"; import { ProfilePicture } from "@/components/entity/ProfilePicture"; @@ -10,10 +19,14 @@ import { SettingsView } from "@/components/layout/SettingsView"; import { useCachedState } from "@/hooks/useCachedState"; import { useInterval } from "@/hooks/useInterval"; import { useAuth } from "@/hooks/useAuth"; -import { api } from "@/api"; import LapseLogo from "@/assets/icon.svg"; +import { WindowedModal } from "@/components/layout/WindowedModal"; +import { ErrorModal } from "@/components/layout/ErrorModal"; +import { LoadingModal } from "@/components/layout/LoadingModal"; +import { FileUploader } from "react-drag-drop-files"; + export function Header() { const auth = useAuth(false); const router = useRouter(); @@ -33,6 +46,147 @@ export function Header() { useInterval(fetchActiveUsers, 30 * 1000); + const [showCreateDropdown, setCreateDropdown] = useState(false) + const [showUploadModal, setUploadModal] = useState(false) + + const [file, setFile] = useState(null); + const [uploadFileName, setUploadFileName] = useState("") + const [thumbnail, setThumbnail] = useState("") + + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStage, setUploadStage] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + + const handleFileUpload = (f: File | File[]) => { + // This should never happen, but the signature of `f` should still container `File[]` to calm down TypeScript + if (Array.isArray(f)) { + return + } + + setFile(f); + setUploadFileName(f.name) + }; + + useAsyncEffect(async () => { + if (file == null) { + return + } + + const reader = new FileReader(); + reader.onload = async () => { + console.log(await videoGenerateThumbnail(file)) + + const r = new FileReader(); + r.readAsDataURL(await videoGenerateThumbnail(file)); + r.onloadend = function () { + setThumbnail(r.result as string); + } + }; + reader.onerror = () => { + setError(`Error reading file ${file.name}`) + }; + + reader.readAsArrayBuffer(file) + }, [file]) + + function bytesProgressCallback(uploaded: number, total: number) { + setUploadProgress((uploaded / total) * 100); + setUploadStage(`Uploading video session... (${prettyBytes(uploaded)}/${prettyBytes(total)})`); + } + + async function uploadCustomFile() { + try { + if (file == null) { + console.log("(Header.tsx) File is null? Whaaa-") + setError("(Header.tsx) File is null? Whaaa-") + return // This shouldn't ever run but I put this here to placate TypeScript + } + + const progress = new SteppedProgress(3, setUploadStage, setUploadProgress); + setIsUploading(true) + progress.advance(0, "Generating thumbnail..."); + const videoThumbnail = await videoGenerateThumbnail(file); + console.log("(Header.tsx) thumbnail generated:", videoThumbnail); + + progress.advance(1, "Talking with the server..."); + const device = await getCurrentDevice(); + + const input = new mediabunny.Input({ + formats: [mediabunny.MATROSKA, mediabunny.MP4, mediabunny.WEBM], + source: new mediabunny.BlobSource(file), + }); + + const videoDurationSeconds = await input.computeDuration() + const snapshotTimestamps: number[] = [] + const now = Date.now() + for (let i = 0; i < (videoDurationSeconds / 2.5); i++) { + snapshotTimestamps.push(now + parseInt((i * 2.5).toString())) + } + + const res = await api.draftTimelapse.create({ + snapshots: snapshotTimestamps, + thumbnailSize: videoThumbnail.size, + deviceId: device.id, + sessions: [{ fileSize: file.size + 8192 }] // we add an 8KiB margin, because encryption adds some marginal overhead, and we don't want to force the user to store every session in memory + }); + + console.log("(Header.tsx) draftTimelapse.create response:", res); + + if (!res.ok) + throw new Error(res.message); + + setUploadProgress(0); + setUploadStage(`Encrypting literally the only session...`); + const encrypted = await encryptData( + fromHex(device.passkey).buffer, + fromHex(res.data.draftTimelapse.iv).buffer, + file + ); + + console.log(`(Header.tsx) encrypted literally the only session:`, encrypted); + + setUploadStage("Uploading video session..."); + await apiUpload(res.data.sessionUploadTokens[0], new Blob([encrypted], { type: (await input.getFormat()).mimeType }), bytesProgressCallback); + + console.log("(Header.tsx) all sessions uploaded successfully! (the was literally only 1 session)"); + // ------------------------------------------------------- // + + progress.advance(2, "Encrypting thumbnail..."); + const encryptedThumb = await encryptData( + fromHex(device.passkey).buffer, + fromHex(res.data.draftTimelapse.iv).buffer, + videoThumbnail + ); + + console.log("(Header.tsx) - encrypted thumbnail:", encryptedThumb); + + await apiUpload( + res.data.thumbnailUploadToken, + new Blob([encryptedThumb], { type: "image/webp" }), + bytesProgressCallback + ); + + console.log("(Header.tsx) thumbnail uploaded successfully! we're done, yay!"); + + posthog.capture("prerecorded_timelapse_upload_completed", { + draft_id: res.data.draftTimelapse.id, + session_count: 1, + snapshot_count: snapshotTimestamps.length, + }); + + router.push(`/draft/${res.data.draftTimelapse.id}`); + } catch (error) { + posthog.capture("prerecorded_timelapse_upload_completed", { error, uploadProgress, uploadStage }); + console.error("(Header.tsx) upload failed:", error); + setIsUploading(false); + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred during upload"; + posthog.capture("prerecorded_timelapse_upload_completed", { error: errorMessage }); + posthog.captureException(error); + setError(errorMessage); + } + } + return ( <>
- +
+ {/* The reason I'm not using the + +
+ {(showCreateDropdown) ? ( +
+ +
+ ) : (<>)} + auth.currentUser && router.push("/timelapse/create")} + onClick={() => setCreateDropdown(!showCreateDropdown)} disabled={!auth.currentUser} - aria-label="Create new timelapse" + aria-label="Open 'create new timelapse' or 'upload custom video' dropdown" > + {(showCreateDropdown) ? ( +
+ + + +
+ ) : (<>)} + { auth.currentUser ? ( + + + + ) : (<>)} + + + + !open && setError(null)} + message={error || ""} + onClose={() => router.back()} + onRetry={() => { + setError(null); + }} + /> ); } \ No newline at end of file diff --git a/apps/client/src/styles/globals.css b/apps/client/src/styles/globals.css index adbbf220..8dca1e72 100644 --- a/apps/client/src/styles/globals.css +++ b/apps/client/src/styles/globals.css @@ -48,6 +48,7 @@ --color-secondary: var(--secondary); --color-primary: #ec3750; --color-accent: var(--accent); + --color-red-accent: #ef596d; --animate-blink: blink 2s infinite steps(1, end); @keyframes blink { diff --git a/apps/devscripts/src/setup-dev-env.ts b/apps/devscripts/src/setup-dev-env.ts index 8d64f54d..b5e990e5 100644 --- a/apps/devscripts/src/setup-dev-env.ts +++ b/apps/devscripts/src/setup-dev-env.ts @@ -17,17 +17,21 @@ const __dirname = dirname(__filename); const DEFAULT_DATABASE_PORT = 5432; const DEFAULT_SERVER_PORT = 3123; const DEFAULT_REDIS_PORT = 6379; +const DEFAULT_DATABASE_USER = "postgres"; +const DEFAULT_DATABASE_PASSWORD = "postgres"; let databasePort = DEFAULT_DATABASE_PORT; let serverPort = DEFAULT_SERVER_PORT; let redisPort = DEFAULT_REDIS_PORT; +let databaseUser = DEFAULT_DATABASE_USER; +let databasePassword = DEFAULT_DATABASE_PASSWORD; function getDatabaseUrl() { - return `postgresql://postgres:postgres@localhost:${databasePort}/lapse?schema=public`; + return `postgresql://${databaseUser}:${databasePassword}@localhost:${databasePort}/lapse?schema=public`; } function getRedisUrl() { - return `redis://localhost:${redisPort}`; + return `redis://localhost:${redisPort}`; } let S3_ENDPOINT = "http://s3.localhost.localstack.cloud:4566"; @@ -270,7 +274,7 @@ async function updateEnvFiles(envVars: { writeEnvFile(clientDir, envVars.client), writeEnvFile(workerDir, envVars.worker), ]); - + spinner.succeed(chalk.green("Environment variables updated successfully")); logInfo(`Updated .env files for ${chalk.italic("server")}, ${chalk.italic("client")}, and ${chalk.italic("worker")}`); } @@ -363,7 +367,7 @@ async function updateDockerComposeFile(localstackImage: string | null) { async function runSetup() { console.clear(); - const TOTAL_STEPS = 8; + const TOTAL_STEPS = 9; let currentStep = 0; try { @@ -379,6 +383,16 @@ async function runSetup() { serverPort = await askForPort("API server", DEFAULT_SERVER_PORT); redisPort = await askForPort("Redis", DEFAULT_REDIS_PORT); + logStep(++currentStep, TOTAL_STEPS, "Configuring database credentials..."); + databaseUser = await input({ + message: `Enter database username (default: ${DEFAULT_DATABASE_USER}): `, + default: DEFAULT_DATABASE_USER, + }); + databasePassword = await input({ + message: `Enter database password (default: ${DEFAULT_DATABASE_PASSWORD}): `, + default: DEFAULT_DATABASE_PASSWORD, + }); + logStep(++currentStep, TOTAL_STEPS, "Configuring storage backend..."); localstackORr2 = await askLocalstackOrR2(); @@ -500,7 +514,7 @@ async function main() { await startDockerCompose(); return; } - + await runSetup(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acbc3a18..c4699137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-drag-drop-files: + specifier: ^3.1.0 + version: 3.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tus-js-client: specifier: ^4.3.1 version: 4.3.1 @@ -1270,6 +1273,15 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -3194,6 +3206,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/stylis@4.2.7': + resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -3770,6 +3785,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001770: resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} @@ -3952,9 +3970,16 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -5726,10 +5751,17 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -5870,6 +5902,12 @@ packages: peerDependencies: react: ^19.2.3 + react-drag-drop-files@3.1.0: + resolution: {integrity: sha512-b2T4NyJP4MLHw/OYd7fuoMLz5NqO5BgqJRIx+rYGY/aihxTF6d4E/9uYezmYdd7KaC6KvnlA0UW2h+jlFQyeWA==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6095,6 +6133,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6278,6 +6319,16 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + styled-components@6.3.11: + resolution: {integrity: sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA==} + engines: {node: '>= 16'} + peerDependencies: + react: '>= 16.8.0' + react-dom: '>= 16.8.0' + peerDependenciesMeta: + react-dom: + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -6291,6 +6342,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -8198,6 +8252,14 @@ snapshots: tslib: 2.8.1 optional: true + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/unitless@0.10.0': {} + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -10284,6 +10346,8 @@ snapshots: '@types/statuses@2.0.6': optional: true + '@types/stylis@4.2.7': {} + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -10962,6 +11026,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-lite@1.0.30001770: {} caniuse-lite@1.0.30001774: {} @@ -11125,6 +11191,8 @@ snapshots: uncrypto: 0.1.3 optional: true + css-color-keywords@1.0.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -11133,6 +11201,12 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@2.2.1: dependencies: mdn-data: 2.0.28 @@ -13121,12 +13195,20 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.4.49: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -13273,6 +13355,13 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-drag-drop-files@3.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-components: 6.3.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-is@16.13.1: {} react-is@17.0.2: {} @@ -13529,6 +13618,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + shallowequal@1.1.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -13758,6 +13849,21 @@ snapshots: strnum@2.1.2: {} + styled-components@6.3.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@emotion/is-prop-valid': 1.4.0 + '@emotion/unitless': 0.10.0 + '@types/stylis': 4.2.7 + css-to-react-native: 3.2.0 + csstype: 3.2.3 + postcss: 8.4.49 + react: 19.2.3 + shallowequal: 1.1.0 + stylis: 4.3.6 + tslib: 2.8.1 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): dependencies: client-only: 0.0.1 @@ -13765,6 +13871,8 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1