mirror of
https://github.com/linsa-io/linsa.git
synced 2026-05-29 18:10:40 +02:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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, [])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user