Compare commits
4
Commits
89e673bb59
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a242869095 | ||
|
|
2c101420cc | ||
|
|
9f8527fc4d | ||
|
|
358bfed988 |
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
Default to using Bun instead of Node.js.
|
||||||
|
|
||||||
|
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||||
|
- Use `bun test` instead of `jest` or `vitest`
|
||||||
|
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||||
|
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||||
|
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||||
|
- Bun automatically loads .env, so don't use dotenv.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||||
|
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||||
|
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||||
|
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||||
|
- `WebSocket` is built-in. Don't use `ws`.
|
||||||
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||||
|
- Bun.$`ls` instead of execa.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use `bun test` to run tests.
|
||||||
|
|
||||||
|
```ts#index.test.ts
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
test("hello world", () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
```ts#index.ts
|
||||||
|
import index from "./index.html"
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
"/": index,
|
||||||
|
"/api/users/:id": {
|
||||||
|
GET: (req) => {
|
||||||
|
return new Response(JSON.stringify({ id: req.params.id }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// optional websocket support
|
||||||
|
websocket: {
|
||||||
|
open: (ws) => {
|
||||||
|
ws.send("Hello, world!");
|
||||||
|
},
|
||||||
|
message: (ws, message) => {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
close: (ws) => {
|
||||||
|
// handle close
|
||||||
|
}
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||||
|
|
||||||
|
```html#index.html
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
<script type="module" src="./frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
With the following `frontend.tsx`:
|
||||||
|
|
||||||
|
```tsx#frontend.tsx
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// import .css files directly and it works
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
const root = createRoot(document.body);
|
||||||
|
|
||||||
|
export default function Frontend() {
|
||||||
|
return <h1>Hello, world!</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.render(<Frontend />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run index.ts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --hot ./index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import { Info, Minus, Plus } from "lucide-react";
|
import { Info, Minus, Plus } from "lucide-react";
|
||||||
import twuiNumberfy from "../../utils/numberfy";
|
import twuiNumberfy from "../../utils/numberfy";
|
||||||
@@ -7,10 +7,10 @@ import { InputProps } from ".";
|
|||||||
let pressInterval: any;
|
let pressInterval: any;
|
||||||
let pressTimeout: any;
|
let pressTimeout: any;
|
||||||
|
|
||||||
type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
|
type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
|
||||||
|
value: string;
|
||||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
getNormalizedValue: (v: string) => void;
|
buttonDownRef: React.RefObject<boolean>;
|
||||||
buttonDownRef: React.MutableRefObject<boolean>;
|
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,21 +18,50 @@ type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
|
|||||||
* # Input Number Text Buttons
|
* # Input Number Text Buttons
|
||||||
*/
|
*/
|
||||||
export default function NumberInputButtons({
|
export default function NumberInputButtons({
|
||||||
getNormalizedValue,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
buttonDownRef,
|
buttonDownRef,
|
||||||
inputRef,
|
inputRef,
|
||||||
|
decimal,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const PRESS_TRIGGER_TIMEOUT = 200;
|
const PRESS_TRIGGER_TIMEOUT = 200;
|
||||||
const DEFAULT_STEP = 1;
|
const DEFAULT_STEP = 1;
|
||||||
|
|
||||||
|
const [buttonDown, setButtonDown] = useState(false);
|
||||||
|
|
||||||
|
// function getNormalizedValue(value: string) {
|
||||||
|
// if (numberText) {
|
||||||
|
// if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
|
||||||
|
// return getFinalValue(props.max);
|
||||||
|
|
||||||
|
// if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
|
||||||
|
// return getFinalValue(props.min);
|
||||||
|
|
||||||
|
// return getFinalValue(value);
|
||||||
|
// } else {
|
||||||
|
// return value;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buttonDownRef.current = buttonDown;
|
||||||
|
if (buttonDown) {
|
||||||
|
setValue(inputRef.current?.value || "");
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
setValue(inputRef.current?.value || "");
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, [buttonDown]);
|
||||||
|
|
||||||
function incrementDownPress() {
|
function incrementDownPress() {
|
||||||
window.clearTimeout(pressTimeout);
|
window.clearTimeout(pressTimeout);
|
||||||
|
setButtonDown(true);
|
||||||
|
|
||||||
pressTimeout = setTimeout(() => {
|
pressTimeout = setTimeout(() => {
|
||||||
buttonDownRef.current = true;
|
|
||||||
pressInterval = setInterval(() => {
|
pressInterval = setInterval(() => {
|
||||||
increment();
|
increment();
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -40,14 +69,15 @@ export default function NumberInputButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function incrementDownCancel() {
|
function incrementDownCancel() {
|
||||||
buttonDownRef.current = false;
|
setButtonDown(false);
|
||||||
window.clearTimeout(pressTimeout);
|
window.clearTimeout(pressTimeout);
|
||||||
window.clearInterval(pressInterval);
|
window.clearInterval(pressInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decrementDownPress() {
|
function decrementDownPress() {
|
||||||
|
setButtonDown(true);
|
||||||
|
|
||||||
pressTimeout = setTimeout(() => {
|
pressTimeout = setTimeout(() => {
|
||||||
buttonDownRef.current = true;
|
|
||||||
pressInterval = setInterval(() => {
|
pressInterval = setInterval(() => {
|
||||||
decrement();
|
decrement();
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -55,41 +85,51 @@ export default function NumberInputButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decrementDownCancel() {
|
function decrementDownCancel() {
|
||||||
buttonDownRef.current = false;
|
setButtonDown(false);
|
||||||
window.clearTimeout(pressTimeout);
|
window.clearTimeout(pressTimeout);
|
||||||
window.clearInterval(pressInterval);
|
window.clearInterval(pressInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function increment() {
|
function increment() {
|
||||||
const existingValue = inputRef.current?.value;
|
if (!inputRef.current) return;
|
||||||
const existingNumberValue = twuiNumberfy(existingValue);
|
|
||||||
|
|
||||||
if (max && existingNumberValue >= twuiNumberfy(max)) {
|
const existingValue = inputRef.current.value;
|
||||||
return setValue(String(max));
|
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||||
} else if (min && existingNumberValue < twuiNumberfy(min)) {
|
|
||||||
return setValue(String(min));
|
let new_value = "";
|
||||||
|
|
||||||
|
if (max && existingNumberValue >= twuiNumberfy(max, decimal)) {
|
||||||
|
new_value = twuiNumberfy(max, decimal).toLocaleString();
|
||||||
|
} else if (min && existingNumberValue < twuiNumberfy(min, decimal)) {
|
||||||
|
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||||
} else {
|
} else {
|
||||||
setValue(
|
new_value = (
|
||||||
String(
|
existingNumberValue +
|
||||||
existingNumberValue + twuiNumberfy(step || DEFAULT_STEP),
|
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||||
),
|
).toLocaleString();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inputRef.current.value = new_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decrement() {
|
function decrement() {
|
||||||
const existingValue = inputRef.current?.value;
|
if (!inputRef.current) return;
|
||||||
const existingNumberValue = twuiNumberfy(existingValue);
|
|
||||||
|
|
||||||
if (min && existingNumberValue <= twuiNumberfy(min)) {
|
const existingValue = inputRef.current?.value;
|
||||||
setValue(String(min));
|
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||||
|
|
||||||
|
let new_value = "";
|
||||||
|
|
||||||
|
if (min && existingNumberValue <= twuiNumberfy(min, decimal)) {
|
||||||
|
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||||
} else {
|
} else {
|
||||||
setValue(
|
new_value = (
|
||||||
String(
|
existingNumberValue -
|
||||||
existingNumberValue - twuiNumberfy(step || DEFAULT_STEP),
|
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||||
),
|
).toLocaleString();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inputRef.current.value = new_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,27 +6,21 @@ import React, {
|
|||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
TextareaHTMLAttributes,
|
TextareaHTMLAttributes,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Span from "../../layout/Span";
|
import Span from "../../layout/Span";
|
||||||
import Button from "../../layout/Button";
|
|
||||||
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
|
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
|
||||||
import { AutocompleteOptions } from "../../types";
|
import { AutocompleteOptions } from "../../types";
|
||||||
import twuiNumberfy from "../../utils/numberfy";
|
import twuiNumberfy from "../../utils/numberfy";
|
||||||
import Dropdown from "../../elements/Dropdown";
|
import Dropdown from "../../elements/Dropdown";
|
||||||
import Card from "../../elements/Card";
|
|
||||||
import Stack from "../../layout/Stack";
|
import Stack from "../../layout/Stack";
|
||||||
import NumberInputButtons from "./NumberInputButtons";
|
import NumberInputButtons from "./NumberInputButtons";
|
||||||
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||||
import twuiUseReady from "../../hooks/useReady";
|
|
||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import Paper from "../../elements/Paper";
|
import Paper from "../../elements/Paper";
|
||||||
import { TWUISelectValidityObject } from "../Select";
|
import { TWUISelectValidityObject } from "../Select";
|
||||||
|
|
||||||
let timeout: any;
|
|
||||||
let validationFnTimeout: any;
|
|
||||||
let externalValueChangeTimeout: any;
|
|
||||||
|
|
||||||
export type InputProps<KeyType extends string> = Omit<
|
export type InputProps<KeyType extends string> = Omit<
|
||||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
"prefix" | "suffix"
|
"prefix" | "suffix"
|
||||||
@@ -80,6 +74,7 @@ export type InputProps<KeyType extends string> = Omit<
|
|||||||
React.HTMLAttributes<HTMLDivElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
|
// refreshDefaultValue?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let refreshes = 0;
|
let refreshes = 0;
|
||||||
@@ -121,9 +116,18 @@ export default function Input<KeyType extends string>(
|
|||||||
validity: existingValidity,
|
validity: existingValidity,
|
||||||
clearInputProps,
|
clearInputProps,
|
||||||
rawNumber,
|
rawNumber,
|
||||||
|
// refreshDefaultValue,
|
||||||
...props
|
...props
|
||||||
} = inputProps;
|
} = inputProps;
|
||||||
|
|
||||||
|
const componentRefreshesRef = useRef(0);
|
||||||
|
let timeoutRef = useRef<any>(null);
|
||||||
|
let validationFnTimeoutRef = useRef<any>(null);
|
||||||
|
let externalValueChangeTimeoutRef = useRef<any>(null);
|
||||||
|
|
||||||
|
refreshes++;
|
||||||
|
componentRefreshesRef.current++;
|
||||||
|
|
||||||
function getFinalValue(v: any) {
|
function getFinalValue(v: any) {
|
||||||
if (rawNumber) return twuiNumberfy(v);
|
if (rawNumber) return twuiNumberfy(v);
|
||||||
if (numberText) {
|
if (numberText) {
|
||||||
@@ -167,21 +171,8 @@ export default function Input<KeyType extends string>(
|
|||||||
props.placeholder ||
|
props.placeholder ||
|
||||||
(props.name ? twuiSlugToNormalText(props.name) : undefined);
|
(props.name ? twuiSlugToNormalText(props.name) : undefined);
|
||||||
|
|
||||||
function getNormalizedValue(value: string) {
|
|
||||||
if (numberText) {
|
|
||||||
if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
|
|
||||||
return getFinalValue(props.max);
|
|
||||||
|
|
||||||
if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
|
|
||||||
return getFinalValue(props.min);
|
|
||||||
|
|
||||||
return getFinalValue(value);
|
|
||||||
} else {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// if (!existingReady) return;
|
||||||
if (!existingValidity) return;
|
if (!existingValidity) return;
|
||||||
setValidity(existingValidity);
|
setValidity(existingValidity);
|
||||||
}, [existingValidity]);
|
}, [existingValidity]);
|
||||||
@@ -190,8 +181,8 @@ export default function Input<KeyType extends string>(
|
|||||||
if (buttonDownRef.current) return;
|
if (buttonDownRef.current) return;
|
||||||
|
|
||||||
if (changeHandler) {
|
if (changeHandler) {
|
||||||
window.clearTimeout(externalValueChangeTimeout);
|
window.clearTimeout(externalValueChangeTimeoutRef.current);
|
||||||
externalValueChangeTimeout = setTimeout(() => {
|
externalValueChangeTimeoutRef.current = setTimeout(() => {
|
||||||
changeHandler(val);
|
changeHandler(val);
|
||||||
}, finalDebounce);
|
}, finalDebounce);
|
||||||
}
|
}
|
||||||
@@ -208,10 +199,10 @@ export default function Input<KeyType extends string>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
if (validationRegex) {
|
if (validationRegex) {
|
||||||
timeout = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
setValidity({
|
setValidity({
|
||||||
isValid: validationRegex.test(val),
|
isValid: validationRegex.test(val),
|
||||||
msg: "Value mismatch",
|
msg: "Value mismatch",
|
||||||
@@ -220,9 +211,9 @@ export default function Input<KeyType extends string>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validationFunction) {
|
if (validationFunction) {
|
||||||
window.clearTimeout(validationFnTimeout);
|
window.clearTimeout(validationFnTimeoutRef.current);
|
||||||
|
|
||||||
validationFnTimeout = setTimeout(() => {
|
validationFnTimeoutRef.current = setTimeout(() => {
|
||||||
if (validationRegex && !validationRegex.test(val)) {
|
if (validationRegex && !validationRegex.test(val)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,11 +226,20 @@ export default function Input<KeyType extends string>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// if (!existingReady) return;
|
||||||
if (typeof props.value !== "string" || !props.value.match(/./)) return;
|
if (typeof props.value !== "string" || !props.value.match(/./)) return;
|
||||||
setValue(String(props.value));
|
setValue(String(props.value));
|
||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
|
// React.useEffect(() => {
|
||||||
|
// if (!refreshDefaultValue) return;
|
||||||
|
// console.log("Name:", props.title || props.name);
|
||||||
|
// console.log("props.defaultValue", props.defaultValue);
|
||||||
|
// // setValue(String(props.defaultValue || ""));
|
||||||
|
// }, [refreshDefaultValue]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// if (!existingReady) return;
|
||||||
if (istextarea && textAreaRef.current) {
|
if (istextarea && textAreaRef.current) {
|
||||||
} else if (inputRef?.current) {
|
} else if (inputRef?.current) {
|
||||||
inputRef.current.value = getFinalValue(value);
|
inputRef.current.value = getFinalValue(value);
|
||||||
@@ -452,11 +452,12 @@ export default function Input<KeyType extends string>(
|
|||||||
<NumberInputButtons
|
<NumberInputButtons
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
getNormalizedValue={getNormalizedValue}
|
value={value}
|
||||||
max={props.max}
|
max={props.max}
|
||||||
min={props.min}
|
min={props.min}
|
||||||
step={props.step}
|
step={props.step}
|
||||||
buttonDownRef={buttonDownRef}
|
buttonDownRef={buttonDownRef}
|
||||||
|
decimal={decimal}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useRef } from "react";
|
||||||
|
|
||||||
type Param = {
|
type Param = {
|
||||||
elementRef?: React.RefObject<Element | undefined>;
|
elementRef?: React.RefObject<Element | undefined>;
|
||||||
@@ -9,8 +9,6 @@ type Param = {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout: any;
|
|
||||||
|
|
||||||
export default function useIntersectionObserver({
|
export default function useIntersectionObserver({
|
||||||
elementRef,
|
elementRef,
|
||||||
className,
|
className,
|
||||||
@@ -19,6 +17,8 @@ export default function useIntersectionObserver({
|
|||||||
delay,
|
delay,
|
||||||
elId,
|
elId,
|
||||||
}: Param) {
|
}: Param) {
|
||||||
|
let timeoutRef = useRef<any>(null);
|
||||||
|
|
||||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||||
const [refresh, setRefresh] = React.useState(0);
|
const [refresh, setRefresh] = React.useState(0);
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ export default function useIntersectionObserver({
|
|||||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||||
(entries, observer) => {
|
(entries, observer) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
timeout = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
setIsIntersecting(true);
|
setIsIntersecting(true);
|
||||||
|
|
||||||
if (removeIntersected) {
|
if (removeIntersected) {
|
||||||
@@ -41,7 +41,7 @@ export default function useIntersectionObserver({
|
|||||||
setIsIntersecting(false);
|
setIsIntersecting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type UseWebsocketHookParams = {
|
|||||||
disableReconnect?: boolean;
|
disableReconnect?: boolean;
|
||||||
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
|
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
|
||||||
keepAliveDuration?: number;
|
keepAliveDuration?: number;
|
||||||
|
/** Interval in ms to force-refresh the connection */
|
||||||
refreshConnection?: number;
|
refreshConnection?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,10 +17,10 @@ export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
|||||||
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
|
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
|
||||||
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
|
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
|
||||||
*
|
*
|
||||||
* @example window.addEventLiatener("wsDataEvent", (e)=>{
|
* @example window.addEventListener("wsDataEvent", (e)=>{
|
||||||
* console.log(e.detail.data) // type object
|
* console.log(e.detail.data) // type object
|
||||||
* })
|
* })
|
||||||
* @example window.addEventLiatener("wsMessageEvent", (e)=>{
|
* @example window.addEventListener("wsMessageEvent", (e)=>{
|
||||||
* console.log(e.detail.message) // type string
|
* console.log(e.detail.message) // type string
|
||||||
* })
|
* })
|
||||||
*/
|
*/
|
||||||
@@ -34,22 +35,29 @@ export default function useWebSocket<
|
|||||||
}: UseWebsocketHookParams) {
|
}: UseWebsocketHookParams) {
|
||||||
const DEBOUNCE = debounce || 500;
|
const DEBOUNCE = debounce || 500;
|
||||||
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
|
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
|
||||||
const KEEP_ALIVE_TIMEOUT = 1000 * 60 * 3;
|
|
||||||
|
|
||||||
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
||||||
|
|
||||||
let uptime = 0;
|
const tries = useRef(0);
|
||||||
let tries = useRef(0);
|
|
||||||
|
|
||||||
// const queue: string[] = [];
|
// Refs to avoid stale closures in callbacks
|
||||||
|
const urlRef = useRef(url);
|
||||||
|
const disableReconnectRef = useRef(disableReconnect);
|
||||||
|
const keepAliveDurationRef = useRef(KEEP_ALIVE_DURATION);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
urlRef.current = url;
|
||||||
|
disableReconnectRef.current = disableReconnect;
|
||||||
|
keepAliveDurationRef.current = KEEP_ALIVE_DURATION;
|
||||||
|
});
|
||||||
|
|
||||||
const msgInterval = useRef<any>(null);
|
const msgInterval = useRef<any>(null);
|
||||||
const sendInterval = useRef<any>(null);
|
const sendInterval = useRef<any>(null);
|
||||||
const keepAliveInterval = useRef<any>(null);
|
const keepAliveInterval = useRef<any>(null);
|
||||||
|
const refreshInterval = useRef<any>(null);
|
||||||
|
const reconnectTimeout = useRef<any>(null);
|
||||||
|
|
||||||
const [socket, setSocket] = React.useState<WebSocket | undefined>(
|
const [socket, setSocket] = React.useState<WebSocket | undefined>(undefined);
|
||||||
undefined
|
const socketRef = useRef<WebSocket | undefined>(undefined);
|
||||||
);
|
|
||||||
|
|
||||||
const messageQueueRef = React.useRef<string[]>([]);
|
const messageQueueRef = React.useRef<string[]>([]);
|
||||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||||
@@ -74,16 +82,17 @@ export default function useWebSocket<
|
|||||||
* # Connect to Websocket
|
* # Connect to Websocket
|
||||||
*/
|
*/
|
||||||
const connect = React.useCallback(() => {
|
const connect = React.useCallback(() => {
|
||||||
|
const currentUrl = urlRef.current;
|
||||||
const domain = window.location.origin;
|
const domain = window.location.origin;
|
||||||
const wsURL = url.startsWith(`ws`)
|
const wsURL = currentUrl.startsWith("ws")
|
||||||
? url
|
? currentUrl
|
||||||
: domain.replace(/^http/, "ws") + ("/" + url).replace(/\/\//g, "/");
|
: domain.replace(/^http/, "ws") + ("/" + currentUrl).replace(/\/\//g, "/");
|
||||||
|
|
||||||
if (!wsURL) return;
|
if (!wsURL) return;
|
||||||
|
|
||||||
let ws = new WebSocket(wsURL);
|
const ws = new WebSocket(wsURL);
|
||||||
|
|
||||||
ws.onerror = (ev) => {
|
ws.onerror = () => {
|
||||||
console.log(`Websocket ERROR:`);
|
console.log(`Websocket ERROR:`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,15 +100,17 @@ export default function useWebSocket<
|
|||||||
messageQueueRef.current.push(ev.data);
|
messageQueueRef.current.push(ev.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onopen = (ev) => {
|
ws.onopen = () => {
|
||||||
window.clearInterval(keepAliveInterval.current);
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
|
||||||
keepAliveInterval.current = window.setInterval(() => {
|
keepAliveInterval.current = window.setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(KEEP_ALIVE_MESSAGE);
|
ws.send(KEEP_ALIVE_MESSAGE);
|
||||||
}
|
}
|
||||||
}, KEEP_ALIVE_DURATION);
|
}, keepAliveDurationRef.current);
|
||||||
|
|
||||||
|
tries.current = 0;
|
||||||
|
socketRef.current = ws;
|
||||||
setSocket(ws);
|
setSocket(ws);
|
||||||
console.log(`Websocket connected to ${wsURL}`);
|
console.log(`Websocket connected to ${wsURL}`);
|
||||||
};
|
};
|
||||||
@@ -111,23 +122,21 @@ export default function useWebSocket<
|
|||||||
wasClean: ev.wasClean,
|
wasClean: ev.wasClean,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disableReconnect) return;
|
|
||||||
|
|
||||||
console.log("Attempting to reconnect ...");
|
|
||||||
console.log("URL:", url);
|
|
||||||
window.clearInterval(keepAliveInterval.current);
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
socketRef.current = undefined;
|
||||||
|
setSocket(undefined);
|
||||||
|
|
||||||
console.log("tries", tries);
|
if (disableReconnectRef.current) return;
|
||||||
|
|
||||||
if (tries.current >= 3) {
|
if (tries.current >= 3) {
|
||||||
|
console.log("Max reconnect attempts reached.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Attempting to reconnect ...");
|
|
||||||
|
|
||||||
tries.current += 1;
|
tries.current += 1;
|
||||||
|
const backoff = Math.min(1000 * 2 ** tries.current, 30000);
|
||||||
connect();
|
console.log(`Attempting to reconnect in ${backoff}ms... (attempt ${tries.current})`);
|
||||||
|
reconnectTimeout.current = window.setTimeout(connect, backoff);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -135,18 +144,40 @@ export default function useWebSocket<
|
|||||||
* # Initial Connection
|
* # Initial Connection
|
||||||
*/
|
*/
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (socket) return;
|
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(reconnectTimeout.current);
|
||||||
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
window.clearInterval(refreshInterval.current);
|
||||||
|
socketRef.current?.close();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Refresh Connection Interval
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!refreshConnection) return;
|
||||||
|
|
||||||
|
refreshInterval.current = window.setInterval(() => {
|
||||||
|
console.log("Refreshing WebSocket connection...");
|
||||||
|
window.clearTimeout(reconnectTimeout.current);
|
||||||
|
socketRef.current?.close();
|
||||||
|
tries.current = 0;
|
||||||
|
connect();
|
||||||
|
}, refreshConnection);
|
||||||
|
|
||||||
|
return () => window.clearInterval(refreshInterval.current);
|
||||||
|
}, [refreshConnection]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
|
||||||
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
|
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
|
||||||
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||||
|
|
||||||
return function () {
|
return () => {
|
||||||
window.clearInterval(sendInterval.current);
|
window.clearInterval(sendInterval.current);
|
||||||
window.clearInterval(msgInterval.current);
|
window.clearInterval(msgInterval.current);
|
||||||
};
|
};
|
||||||
@@ -173,7 +204,8 @@ export default function useWebSocket<
|
|||||||
* Send Message Queue Handler
|
* Send Message Queue Handler
|
||||||
*/
|
*/
|
||||||
const handleSendMessageQueue = React.useCallback(() => {
|
const handleSendMessageQueue = React.useCallback(() => {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
const ws = socketRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
window.clearInterval(sendInterval.current);
|
window.clearInterval(sendInterval.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,29 +213,26 @@ export default function useWebSocket<
|
|||||||
const newMessage = sendMessageQueueRef.current.shift();
|
const newMessage = sendMessageQueueRef.current.shift();
|
||||||
if (!newMessage) return;
|
if (!newMessage) return;
|
||||||
|
|
||||||
socket.send(newMessage);
|
ws.send(newMessage);
|
||||||
}, [socket]);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Send Data Function
|
* # Send Data Function
|
||||||
*/
|
*/
|
||||||
const sendData = React.useCallback(
|
const sendData = React.useCallback((data: T) => {
|
||||||
(data: T) => {
|
try {
|
||||||
try {
|
const queueItemJSON = JSON.stringify(data);
|
||||||
const queueItemJSON = JSON.stringify(data);
|
|
||||||
|
|
||||||
const existingQueue = sendMessageQueueRef.current.find(
|
const existingQueue = sendMessageQueueRef.current.find(
|
||||||
(q) => q == queueItemJSON
|
(q) => q === queueItemJSON
|
||||||
);
|
);
|
||||||
if (!existingQueue) {
|
if (!existingQueue) {
|
||||||
sendMessageQueueRef.current.push(queueItemJSON);
|
sendMessageQueueRef.current.push(queueItemJSON);
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.log("Error Sending socket message", error.message);
|
|
||||||
}
|
}
|
||||||
},
|
} catch (error: any) {
|
||||||
[socket]
|
console.log("Error Sending socket message", error.message);
|
||||||
);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return { socket, sendData };
|
return { socket, sendData };
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-8
@@ -9,18 +9,31 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tailwind-merge": "^2.5.4",
|
"gray-matter": "^4.0.3",
|
||||||
|
"html-to-react": "^1.7.0",
|
||||||
|
"lodash": "^4.17.23",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"mdx": "^0.3.1",
|
||||||
|
"next-mdx-remote": "^6.0.0",
|
||||||
|
"openai": "^6.25.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.14"
|
"react-code-blocks": "^0.1.6",
|
||||||
|
"react-responsive-modal": "^7.1.0",
|
||||||
|
"rehype-prism-plus": "^2.0.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"next": "14.2.15",
|
"@next/mdx": "^16.1.6",
|
||||||
"react": "^18",
|
"@types/ace": "^0.0.52",
|
||||||
"react-dom": "^18",
|
"@types/bun": "latest",
|
||||||
"typescript": "^5",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^18"
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"@types/lodash": "^4.17.23"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./components"
|
".": "./components"
|
||||||
|
|||||||
Reference in New Issue
Block a user