Move to TanStack Start from Next.js (#184)

This commit is contained in:
Aslam
2024-10-07 16:44:17 +07:00
committed by GitHub
parent 3a89a1c07f
commit 950ebc3dad
514 changed files with 20021 additions and 15508 deletions
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react"
import { toast } from "sonner"
import { LaAccount, PersonalLink } from "@/lib/schema"
export const useLinkActions = () => {
const deleteLink = React.useCallback((me: LaAccount, link: PersonalLink) => {
if (!me.root?.personalLinks) return
try {
const index = me.root.personalLinks.findIndex(
(item) => item?.id === link.id,
)
if (index === -1) {
throw new Error(`Link with id ${link.id} not found`)
}
me.root.personalLinks.splice(index, 1)
toast.success("Link deleted.", {
position: "bottom-right",
description: `${link.title} has been deleted.`,
})
} catch (error) {
console.error("Failed to delete link:", error)
toast.error("Failed to delete link", {
description:
error instanceof Error ? error.message : "An unknown error occurred",
})
}
}, [])
return {
deleteLink,
}
}
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
import { toast } from "sonner"
import { LaAccount, PersonalPage } from "@/lib/schema"
import { ID } from "jazz-tools"
export const usePageActions = () => {
const newPage = React.useCallback((me: LaAccount): PersonalPage => {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner },
)
me.root?.personalPages?.push(newPersonalPage)
return newPersonalPage
}, [])
const deletePage = React.useCallback(
(me: LaAccount, pageId: ID<PersonalPage>): void => {
if (!me.root?.personalPages) return
const index = me.root.personalPages.findIndex(
(item) => item?.id === pageId,
)
if (index === -1) {
toast.error("Page not found")
return
}
const page = me.root.personalPages[index]
if (!page) {
toast.error("Page data is invalid")
return
}
try {
me.root.personalPages.splice(index, 1)
toast.success("Page deleted", {
position: "bottom-right",
description: `${page.title} has been deleted.`,
})
} catch (error) {
console.error("Failed to delete page", error)
toast.error("Failed to delete page")
}
},
[],
)
return { newPage, deletePage }
}
+61
View File
@@ -0,0 +1,61 @@
import { useCallback } from "react"
import { toast } from "sonner"
import { LaAccount } from "@/lib/schema"
import { ID } from "jazz-tools"
import { ListOfTasks, Task } from "~/lib/schema/task"
export const useTaskActions = () => {
const newTask = useCallback((me: LaAccount): Task | null => {
if (!me.root) {
console.error("User root is not initialized")
return null
}
if (!me.root.tasks) {
me.root.tasks = ListOfTasks.create([], { owner: me })
}
const newTask = Task.create(
{
title: "",
description: "",
status: "todo",
createdAt: new Date(),
updatedAt: new Date(),
},
{ owner: me._owner },
)
me.root.tasks.push(newTask)
return newTask
}, [])
const deleteTask = useCallback((me: LaAccount, taskId: ID<Task>): void => {
if (!me.root?.tasks) return
const index = me.root.tasks.findIndex((item) => item?.id === taskId)
if (index === -1) {
toast.error("Task not found")
return
}
const task = me.root.tasks[index]
if (!task) {
toast.error("Task data is invalid")
return
}
try {
me.root.tasks.splice(index, 1)
toast.success("Task completed", {
position: "bottom-right",
})
} catch (error) {
console.error("Failed to delete task", error)
toast.error("Failed to delete task")
}
}, [])
return { newTask, deleteTask }
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
type ElementRef<T extends HTMLElement> = T | null
type ElementRefs<T extends HTMLElement> = ElementRef<T>[]
interface ActiveItemScrollOptions {
activeIndex: number | null
}
export function useActiveItemScroll<T extends HTMLElement>(
options: ActiveItemScrollOptions,
) {
const { activeIndex } = options
const elementRefs = React.useRef<ElementRefs<T>>([])
const scrollActiveElementIntoView = React.useCallback((index: number) => {
const activeElement = elementRefs.current[index]
activeElement?.focus()
// activeElement?.scrollIntoView({ block: "nearest" })
}, [])
React.useEffect(() => {
if (activeIndex !== null) {
scrollActiveElementIntoView(activeIndex)
}
}, [activeIndex, scrollActiveElementIntoView])
const setElementRef = React.useCallback(
(element: ElementRef<T>, index: number) => {
elementRefs.current[index] = element
},
[],
)
return { setElementRef, scrollActiveElementIntoView }
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import type { NavigateOptions } from "@tanstack/react-router"
import { useLocation, useNavigate } from "@tanstack/react-router"
type Resolve = (value?: unknown) => void
export const useAwaitableNavigate = () => {
const navigate = useNavigate()
const location = useLocation()
const resolveFunctionsRef = React.useRef<Resolve[]>([])
const resolveAll = () => {
resolveFunctionsRef.current.forEach((resolve) => resolve())
resolveFunctionsRef.current.splice(0, resolveFunctionsRef.current.length)
}
const [, startTransition] = React.useTransition()
React.useEffect(() => {
resolveAll()
}, [location])
return (options: NavigateOptions) => {
return new Promise((res) => {
startTransition(() => {
resolveFunctionsRef.current.push(res)
res(navigate(options))
})
})
}
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import { ensureUrlProtocol } from "@/lib/utils"
import { useTheme } from "next-themes"
import { toast } from "sonner"
import { LaAccount } from "@/lib/schema"
import { usePageActions } from "./actions/use-page-actions"
import { useNavigate } from "@tanstack/react-router"
export const useCommandActions = () => {
const { setTheme } = useTheme()
const navigate = useNavigate()
const { newPage } = usePageActions()
const changeTheme = React.useCallback(
(theme: string) => {
setTheme(theme)
toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" })
},
[setTheme],
)
const navigateTo = React.useCallback(
(path: string) => {
navigate({ to: path })
},
[navigate],
)
const openLinkInNewTab = React.useCallback((url: string) => {
window.open(ensureUrlProtocol(url), "_blank")
}, [])
const copyCurrentURL = React.useCallback(() => {
navigator.clipboard.writeText(window.location.href)
toast.success("URL copied to clipboard.", { position: "bottom-right" })
}, [])
const createNewPage = React.useCallback(
(me: LaAccount) => {
const page = newPage(me)
navigate({ to: `/pages/${page.id}` })
},
[navigate, newPage],
)
return {
changeTheme,
navigateTo,
openLinkInNewTab,
copyCurrentURL,
createNewPage,
}
}
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react"
type EventMap = WindowEventMap & HTMLElementEventMap & VisualViewportEventMap
export function useEventListener<
K extends keyof EventMap,
T extends Window | HTMLElement | VisualViewport | null = Window,
>(
eventName: K,
handler: (event: EventMap[K]) => void,
element: T = window as unknown as T, // Cast to `unknown` first, then `T`
options: AddEventListenerOptions = {},
) {
const savedHandler = React.useRef<(event: EventMap[K]) => void>()
const { capture, passive, once } = options
React.useEffect(() => {
savedHandler.current = handler
}, [handler])
React.useEffect(() => {
const isSupported = element && element.addEventListener
if (!isSupported) return
const eventListener = (event: EventMap[K]) => savedHandler.current?.(event)
const opts = { capture, passive, once }
element.addEventListener(eventName, eventListener as EventListener, opts)
return () => {
element.removeEventListener(
eventName,
eventListener as EventListener,
opts,
)
}
}, [eventName, element, capture, passive, once])
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export function useIsMounted() {
const isMounted = React.useRef(false)
React.useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
return React.useCallback(() => isMounted.current, [])
}
+77
View File
@@ -0,0 +1,77 @@
import * as React from "react"
import { isModKey, isServer, isTextInput } from "@/lib/utils"
export type KeyFilter = ((event: KeyboardEvent) => boolean) | string
export type Options = { allowInInput?: boolean }
type RegisteredCallback = {
callback: (event: KeyboardEvent) => void
options?: Options
}
let callbacks: RegisteredCallback[] = []
let isInitialized = false
const initializeKeyboardListeners = () => {
if (isServer() || isInitialized) return
let imeOpen = false
window.addEventListener("keydown", (event) => {
if (imeOpen) return
for (const registered of [...callbacks].reverse()) {
if (event.defaultPrevented) break
if (
!isTextInput(event.target as HTMLElement) ||
registered.options?.allowInInput ||
isModKey(event)
) {
registered.callback(event)
}
}
})
window.addEventListener("compositionstart", () => {
imeOpen = true
})
window.addEventListener("compositionend", () => {
imeOpen = false
})
isInitialized = true
}
const createKeyPredicate = (keyFilter: KeyFilter) =>
typeof keyFilter === "function"
? keyFilter
: typeof keyFilter === "string"
? (event: KeyboardEvent) => event.key === keyFilter
: keyFilter
? () => true
: () => false
export function useKeyDown(
key: KeyFilter,
fn: (event: KeyboardEvent) => void,
options?: Options,
): void {
const predicate = React.useMemo(() => createKeyPredicate(key), [key])
React.useEffect(() => {
initializeKeyboardListeners()
const handler = (event: KeyboardEvent) => {
if (predicate(event)) {
fn(event)
}
}
callbacks.push({ callback: handler, options })
return () => {
callbacks = callbacks.filter((cb) => cb.callback !== handler)
}
}, [fn, predicate, options])
}
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react"
import { useAtom } from "jotai"
import { keyboardDisableSourcesAtom } from "@/store/keyboard-manager"
const allowedKeys = [
"Escape",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Enter",
"Tab",
"Backspace",
"Home",
"End",
]
export function useKeyboardManager(sourceId: string) {
const [disableSources, setDisableSources] = useAtom(
keyboardDisableSourcesAtom,
)
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (disableSources.has(sourceId)) {
if (allowedKeys.includes(event.key)) {
if (event.key === "Escape") {
setDisableSources((prev) => {
const next = new Set(prev)
next.delete(sourceId)
return next
})
}
} else {
event.stopPropagation()
}
}
}
window.addEventListener("keydown", handleKeyDown, true)
return () => window.removeEventListener("keydown", handleKeyDown, true)
}, [disableSources, sourceId, setDisableSources])
const disableKeydown = React.useCallback(
(disable: boolean) => {
setDisableSources((prev) => {
const next = new Set(prev)
if (disable) {
next.add(sourceId)
} else {
next.delete(sourceId)
}
return next
})
},
[setDisableSources, sourceId],
)
const isKeyboardDisabled = disableSources.has(sourceId)
return { disableKeydown, isKeyboardDisabled }
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
export function useMedia(query: string): boolean {
const [matches, setMatches] = React.useState<boolean>(false)
React.useEffect(() => {
if (window.matchMedia) {
const media = window.matchMedia(query)
if (media.matches !== matches) {
setMatches(media.matches)
}
const listener = () => {
setMatches(media.matches)
}
media.addListener(listener)
return () => media.removeListener(listener)
}
return undefined
}, [matches, query])
return matches
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import { useEventListener } from "./use-event-listener"
/**
* Hook to detect clicks outside of a specified element.
*
* @param ref The React ref to the element.
* @param callback The handler to call when a click outside the element is detected.
*/
export function useOnClickOutside(
ref: React.RefObject<HTMLElement | null>,
callback?: (event: MouseEvent | TouchEvent) => void,
options: AddEventListenerOptions = {},
) {
const listener = React.useCallback(
(event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) {
return
}
callback?.(event)
},
[ref, callback],
)
useEventListener("mousedown", listener, window, options)
useEventListener("touchstart", listener, window, options)
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
export const useTheme = () => {
const [isDarkMode, setIsDarkMode] = React.useState(false)
React.useEffect(() => {
const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
setIsDarkMode(darkModeMediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => {
const newDarkMode = e.matches
setIsDarkMode(newDarkMode)
}
darkModeMediaQuery.addEventListener("change", handleChange)
return () => {
darkModeMediaQuery.removeEventListener("change", handleChange)
}
}, [])
return isDarkMode
}
export default useTheme
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import { isClient } from "~/lib/utils"
export function useTouchSensor() {
const [isTouchDevice, setIsTouchDevice] = React.useState(false)
React.useEffect(() => {
const detectTouch = () => {
setIsTouchDevice(
isClient() &&
(window.matchMedia?.("(hover: none) and (pointer: coarse)")
?.matches ||
"ontouchstart" in window ||
navigator.maxTouchPoints > 0),
)
}
detectTouch()
window.addEventListener("touchstart", detectTouch, false)
return () => {
window.removeEventListener("touchstart", detectTouch)
}
}, [])
return isTouchDevice
}