mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
45
web/app/lib/constants.ts
Normal file
45
web/app/lib/constants.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ID } from "jazz-tools"
|
||||
import { icons } from "lucide-react"
|
||||
import { PublicGlobalGroup } from "./schema/master/public-group"
|
||||
import { getEnvVariable } from "./utils"
|
||||
import Graph from "@/data/graph.json"
|
||||
|
||||
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
|
||||
export type LearningState = {
|
||||
label: string
|
||||
value: LearningStateValue
|
||||
icon: keyof typeof icons
|
||||
className: string
|
||||
}
|
||||
export interface GraphNode {
|
||||
name: string
|
||||
prettyName: string
|
||||
connectedTopics: string[]
|
||||
}
|
||||
|
||||
export const LEARNING_STATES: LearningState[] = [
|
||||
{
|
||||
label: "To Learn",
|
||||
value: "wantToLearn",
|
||||
icon: "Bookmark",
|
||||
className: "text-foreground",
|
||||
},
|
||||
{
|
||||
label: "Learning",
|
||||
value: "learning",
|
||||
icon: "GraduationCap",
|
||||
className: "text-[#D29752]",
|
||||
},
|
||||
{
|
||||
label: "Learned",
|
||||
value: "learned",
|
||||
icon: "Check",
|
||||
className: "text-[#708F51]",
|
||||
},
|
||||
] as const
|
||||
|
||||
export const JAZZ_GLOBAL_GROUP_ID = getEnvVariable(
|
||||
"VITE_JAZZ_GLOBAL_GROUP_ID",
|
||||
) as ID<PublicGlobalGroup>
|
||||
|
||||
export const GraphData = Graph as GraphNode[]
|
||||
23
web/app/lib/providers/clerk-provider.tsx
Normal file
23
web/app/lib/providers/clerk-provider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ClerkProvider as BaseClerkProvider } from "@clerk/tanstack-start"
|
||||
import { dark } from "@clerk/themes"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
interface ClerkProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const ClerkProvider: React.FC<ClerkProviderProps> = ({ children }) => {
|
||||
const { theme, systemTheme } = useTheme()
|
||||
|
||||
const isDarkTheme =
|
||||
theme === "dark" || (theme === "system" && systemTheme === "dark")
|
||||
|
||||
const appearance = {
|
||||
baseTheme: isDarkTheme ? dark : undefined,
|
||||
variables: { colorPrimary: isDarkTheme ? "#dddddd" : "#2e2e2e" },
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseClerkProvider appearance={appearance}>{children}</BaseClerkProvider>
|
||||
)
|
||||
}
|
||||
71
web/app/lib/providers/jazz-provider.tsx
Normal file
71
web/app/lib/providers/jazz-provider.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createJazzReactApp } from "jazz-react"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { useJazzClerkAuth } from "jazz-react-auth-clerk"
|
||||
import { useAuth, useClerk } from "@clerk/tanstack-start"
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
import { getEnvVariable } from "../utils"
|
||||
import { AuthMethod } from "jazz-tools"
|
||||
|
||||
const Jazz = createJazzReactApp({
|
||||
AccountSchema: LaAccount,
|
||||
})
|
||||
|
||||
export const { useAccount, useAccountOrGuest, useCoState, useAcceptInvite } =
|
||||
Jazz
|
||||
|
||||
function assertPeerUrl(
|
||||
url: string | undefined,
|
||||
): asserts url is `wss://${string}` | `ws://${string}` {
|
||||
if (!url) {
|
||||
throw new Error("JAZZ_PEER_URL is not defined")
|
||||
}
|
||||
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
|
||||
throw new Error("JAZZ_PEER_URL must start with wss:// or ws://")
|
||||
}
|
||||
}
|
||||
|
||||
const JAZZ_PEER_URL = (() => {
|
||||
const rawUrl = getEnvVariable("VITE_JAZZ_PEER_URL")
|
||||
assertPeerUrl(rawUrl)
|
||||
return rawUrl
|
||||
})()
|
||||
|
||||
interface ChildrenProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function JazzAndAuth({ children }: ChildrenProps) {
|
||||
const { pathname } = useLocation()
|
||||
const Component = pathname === "/" ? JazzGuest : JazzAuth
|
||||
return <Component>{children}</Component>
|
||||
}
|
||||
|
||||
export function JazzAuth({ children }: ChildrenProps) {
|
||||
const clerk = useClerk()
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const [authMethod] = useJazzClerkAuth(clerk)
|
||||
|
||||
if (!isLoaded) return null
|
||||
if (!isSignedIn) return <JazzGuest>{children}</JazzGuest>
|
||||
if (!authMethod) return null
|
||||
|
||||
return <JazzProvider auth={authMethod}>{children}</JazzProvider>
|
||||
}
|
||||
|
||||
export function JazzGuest({ children }: ChildrenProps) {
|
||||
return <JazzProvider auth="guest">{children}</JazzProvider>
|
||||
}
|
||||
|
||||
function JazzProvider({
|
||||
auth,
|
||||
children,
|
||||
}: {
|
||||
auth: AuthMethod | "guest"
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Jazz.Provider auth={auth} peer={JAZZ_PEER_URL}>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
)
|
||||
}
|
||||
74
web/app/lib/schema/index.ts
Normal file
74
web/app/lib/schema/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { CoMap, co, Account, Profile } from "jazz-tools"
|
||||
import { PersonalPageLists } from "./personal-page"
|
||||
import { PersonalLinkLists } from "./personal-link"
|
||||
import { ListOfTopics } from "./master/topic"
|
||||
import { ListOfTasks } from "./task"
|
||||
import { JournalEntryLists } from "./journal"
|
||||
|
||||
declare module "jazz-tools" {
|
||||
interface Profile {
|
||||
avatarUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
export class UserRoot extends CoMap {
|
||||
name = co.string
|
||||
username = co.string
|
||||
avatar = co.optional.string
|
||||
website = co.optional.string
|
||||
bio = co.optional.string
|
||||
is_public = co.optional.boolean
|
||||
|
||||
personalLinks = co.ref(PersonalLinkLists)
|
||||
personalPages = co.ref(PersonalPageLists)
|
||||
|
||||
topicsWantToLearn = co.ref(ListOfTopics)
|
||||
topicsLearning = co.ref(ListOfTopics)
|
||||
topicsLearned = co.ref(ListOfTopics)
|
||||
|
||||
tasks = co.ref(ListOfTasks)
|
||||
journalEntries = co.ref(JournalEntryLists)
|
||||
}
|
||||
|
||||
export class LaAccount extends Account {
|
||||
profile = co.ref(Profile)
|
||||
root = co.ref(UserRoot)
|
||||
|
||||
migrate(
|
||||
this: LaAccount,
|
||||
creationProps?: { name: string; avatarUrl?: string },
|
||||
) {
|
||||
// since we dont have a custom AuthProvider yet.
|
||||
// and still using the DemoAuth. the creationProps will only accept name.
|
||||
// so just do default profile create provided by jazz-tools
|
||||
super.migrate(creationProps)
|
||||
|
||||
if (!this._refs.root && creationProps) {
|
||||
this.root = UserRoot.create(
|
||||
{
|
||||
name: creationProps.name,
|
||||
username: creationProps.name,
|
||||
avatar: creationProps.avatarUrl || "",
|
||||
website: "",
|
||||
bio: "",
|
||||
is_public: false,
|
||||
|
||||
personalLinks: PersonalLinkLists.create([], { owner: this }),
|
||||
personalPages: PersonalPageLists.create([], { owner: this }),
|
||||
|
||||
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
|
||||
topicsLearning: ListOfTopics.create([], { owner: this }),
|
||||
topicsLearned: ListOfTopics.create([], { owner: this }),
|
||||
|
||||
tasks: ListOfTasks.create([], { owner: this }),
|
||||
journalEntries: JournalEntryLists.create([], { owner: this }),
|
||||
},
|
||||
{ owner: this },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./master/topic"
|
||||
export * from "./personal-link"
|
||||
export * from "./personal-page"
|
||||
11
web/app/lib/schema/journal.ts
Normal file
11
web/app/lib/schema/journal.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
|
||||
export class JournalEntry extends CoMap {
|
||||
title = co.string
|
||||
content = co.json()
|
||||
date = co.encoded(Encoders.Date)
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
}
|
||||
|
||||
export class JournalEntryLists extends CoList.Of(co.ref(JournalEntry)) {}
|
||||
15
web/app/lib/schema/master/force-graph.ts
Normal file
15
web/app/lib/schema/master/force-graph.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { co, CoList, CoMap } from "jazz-tools"
|
||||
|
||||
export class Connection extends CoMap {
|
||||
name = co.string
|
||||
}
|
||||
|
||||
export class ListOfConnections extends CoList.Of(co.ref(Connection)) {}
|
||||
|
||||
export class ForceGraph extends CoMap {
|
||||
name = co.string
|
||||
prettyName = co.string
|
||||
connections = co.optional.ref(ListOfConnections)
|
||||
}
|
||||
|
||||
export class ListOfForceGraphs extends CoList.Of(co.ref(ForceGraph)) {}
|
||||
12
web/app/lib/schema/master/public-group.ts
Normal file
12
web/app/lib/schema/master/public-group.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { co, CoMap, Group } from "jazz-tools"
|
||||
import { ListOfForceGraphs } from "./force-graph"
|
||||
import { ListOfTopics } from "./topic"
|
||||
|
||||
export class PublicGlobalGroupRoot extends CoMap {
|
||||
forceGraphs = co.ref(ListOfForceGraphs)
|
||||
topics = co.ref(ListOfTopics)
|
||||
}
|
||||
|
||||
export class PublicGlobalGroup extends Group {
|
||||
root = co.ref(PublicGlobalGroupRoot)
|
||||
}
|
||||
35
web/app/lib/schema/master/topic.ts
Normal file
35
web/app/lib/schema/master/topic.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { co, CoList, CoMap } from "jazz-tools"
|
||||
|
||||
export class Link extends CoMap {
|
||||
title = co.string
|
||||
url = co.string
|
||||
}
|
||||
|
||||
export class ListOfLinks extends CoList.Of(co.ref(Link)) {}
|
||||
|
||||
export class Section extends CoMap {
|
||||
title = co.string
|
||||
links = co.ref(ListOfLinks)
|
||||
}
|
||||
|
||||
export class ListOfSections extends CoList.Of(co.ref(Section)) {}
|
||||
|
||||
export class LatestGlobalGuide extends CoMap {
|
||||
sections = co.ref(ListOfSections)
|
||||
}
|
||||
|
||||
export class TopicConnection extends CoMap {
|
||||
name = co.string
|
||||
}
|
||||
|
||||
export class ListOfTopicConnections extends CoList.Of(
|
||||
co.ref(TopicConnection),
|
||||
) {}
|
||||
|
||||
export class Topic extends CoMap {
|
||||
name = co.string
|
||||
prettyName = co.string
|
||||
latestGlobalGuide = co.ref(LatestGlobalGuide)
|
||||
}
|
||||
|
||||
export class ListOfTopics extends CoList.Of(co.ref(Topic)) {}
|
||||
24
web/app/lib/schema/personal-link.ts
Normal file
24
web/app/lib/schema/personal-link.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
import { Link, Topic } from "./master/topic"
|
||||
|
||||
class BaseModel extends CoMap {
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
}
|
||||
|
||||
export class PersonalLink extends BaseModel {
|
||||
url = co.string
|
||||
icon = co.optional.string // is an icon URL
|
||||
link = co.optional.ref(Link)
|
||||
title = co.string
|
||||
slug = co.string
|
||||
description = co.optional.string
|
||||
completed = co.boolean
|
||||
sequence = co.number
|
||||
learningState = co.optional.literal("wantToLearn", "learning", "learned")
|
||||
notes = co.optional.string
|
||||
summary = co.optional.string
|
||||
topic = co.optional.ref(Topic)
|
||||
}
|
||||
|
||||
export class PersonalLinkLists extends CoList.Of(co.ref(PersonalLink)) {}
|
||||
21
web/app/lib/schema/personal-page.ts
Normal file
21
web/app/lib/schema/personal-page.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
import { Topic } from "./master/topic"
|
||||
|
||||
/*
|
||||
* Page, content that user can write to. Similar to Notion/Reflect page. It holds ProseMirror editor content + metadata.
|
||||
* - slug: make it unique
|
||||
* - Public Access, url should be learn-anything.xyz/@user/slug
|
||||
* - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user)
|
||||
*/
|
||||
export class PersonalPage extends CoMap {
|
||||
title = co.optional.string
|
||||
slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug`
|
||||
public = co.boolean
|
||||
content = co.optional.json()
|
||||
topic = co.optional.ref(Topic)
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
// backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
|
||||
}
|
||||
|
||||
export class PersonalPageLists extends CoList.Of(co.ref(PersonalPage)) {}
|
||||
12
web/app/lib/schema/task.ts
Normal file
12
web/app/lib/schema/task.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||
|
||||
export class Task extends CoMap {
|
||||
title = co.string
|
||||
description = co.optional.string
|
||||
status = co.literal("todo", "in_progress", "done")
|
||||
createdAt = co.encoded(Encoders.Date)
|
||||
updatedAt = co.encoded(Encoders.Date)
|
||||
dueDate = co.optional.encoded(Encoders.Date)
|
||||
}
|
||||
|
||||
export class ListOfTasks extends CoList.Of(co.ref(Task)) {}
|
||||
33
web/app/lib/utils/env.ts
Normal file
33
web/app/lib/utils/env.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
*
|
||||
* Utility function to get env variables.
|
||||
*
|
||||
* @param name env variable name
|
||||
* @param defaultVaue default value to return if the env variable is not set
|
||||
* @returns string
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const getEnvVariable = (
|
||||
name: string,
|
||||
defaultVaue: string = "",
|
||||
): string => {
|
||||
// Node envs
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
process.env &&
|
||||
typeof process.env[name] === "string"
|
||||
) {
|
||||
return (process.env[name] as string) || defaultVaue
|
||||
}
|
||||
|
||||
if (
|
||||
typeof import.meta !== "undefined" &&
|
||||
import.meta.env &&
|
||||
typeof import.meta.env[name] === "string"
|
||||
) {
|
||||
return import.meta.env[name]
|
||||
}
|
||||
|
||||
return defaultVaue
|
||||
}
|
||||
54
web/app/lib/utils/force-graph/canvas.ts
Normal file
54
web/app/lib/utils/force-graph/canvas.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Resizes the canvas to match the size it is being displayed.
|
||||
*
|
||||
* @param canvas the canvas to resize
|
||||
* @returns `true` if the canvas was resized
|
||||
*/
|
||||
export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
|
||||
// Get the size the browser is displaying the canvas in device pixels.
|
||||
const dpr = window.devicePixelRatio
|
||||
const { width, height } = canvas.getBoundingClientRect()
|
||||
const display_width = Math.round(width * dpr)
|
||||
const display_height = Math.round(height * dpr)
|
||||
|
||||
const need_resize =
|
||||
canvas.width != display_width || canvas.height != display_height
|
||||
|
||||
if (need_resize) {
|
||||
canvas.width = display_width
|
||||
canvas.height = display_height
|
||||
}
|
||||
|
||||
return need_resize
|
||||
}
|
||||
|
||||
export interface CanvasResizeObserver {
|
||||
/** Canvas was resized since last check. Set it to `false` to reset. */
|
||||
resized: boolean
|
||||
canvas: HTMLCanvasElement
|
||||
observer: ResizeObserver
|
||||
}
|
||||
|
||||
export function resize(observer: CanvasResizeObserver): boolean {
|
||||
const resized = resizeCanvasToDisplaySize(observer.canvas)
|
||||
observer.resized ||= resized
|
||||
return resized
|
||||
}
|
||||
|
||||
export function resizeObserver(
|
||||
canvas: HTMLCanvasElement,
|
||||
): CanvasResizeObserver {
|
||||
const cro: CanvasResizeObserver = {
|
||||
resized: false,
|
||||
canvas: canvas,
|
||||
observer: null!,
|
||||
}
|
||||
cro.observer = new ResizeObserver(resize.bind(null, cro))
|
||||
resize(cro)
|
||||
cro.observer.observe(canvas)
|
||||
return cro
|
||||
}
|
||||
|
||||
export function clear(observer: CanvasResizeObserver): void {
|
||||
observer.observer.disconnect()
|
||||
}
|
||||
2
web/app/lib/utils/force-graph/index.ts
Normal file
2
web/app/lib/utils/force-graph/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./canvas"
|
||||
export * from "./schedule"
|
||||
151
web/app/lib/utils/force-graph/schedule.ts
Normal file
151
web/app/lib/utils/force-graph/schedule.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
export interface Scheduler<Args extends unknown[]> {
|
||||
trigger: (...args: Args) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a callback that is debounced and cancellable. The debounced callback is called on **trailing** edge.
|
||||
*
|
||||
* @param callback The callback to debounce
|
||||
* @param wait The duration to debounce in milliseconds
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const debounce = schedule.debounce((message: string) => console.log(message), 250)
|
||||
* debounce.trigger('Hello!')
|
||||
* debounce.clear() // clears a timeout in progress
|
||||
* ```
|
||||
*/
|
||||
export function debounce<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
wait?: number,
|
||||
): Debounce<Args> {
|
||||
return new Debounce(callback, wait)
|
||||
}
|
||||
|
||||
export class Debounce<Args extends unknown[]> implements Scheduler<Args> {
|
||||
timeout_id: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(
|
||||
public callback: (...args: Args) => void,
|
||||
public wait?: number,
|
||||
) {}
|
||||
|
||||
trigger(...args: Args): void {
|
||||
if (this.timeout_id !== undefined) {
|
||||
this.clear()
|
||||
}
|
||||
this.timeout_id = setTimeout(() => {
|
||||
this.callback(...args)
|
||||
}, this.wait)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
clearTimeout(this.timeout_id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a callback that is throttled and cancellable. The throttled callback is called on **trailing** edge.
|
||||
*
|
||||
* @param callback The callback to throttle
|
||||
* @param wait The duration to throttle
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const throttle = schedule.throttle((val: string) => console.log(val), 250)
|
||||
* throttle.trigger('my-new-value')
|
||||
* throttle.clear() // clears a timeout in progress
|
||||
* ```
|
||||
*/
|
||||
export function throttle<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
wait?: number,
|
||||
): Throttle<Args> {
|
||||
return new Throttle(callback, wait)
|
||||
}
|
||||
|
||||
export class Throttle<Args extends unknown[]> implements Scheduler<Args> {
|
||||
is_throttled = false
|
||||
timeout_id: ReturnType<typeof setTimeout> | undefined
|
||||
last_args: Args | undefined
|
||||
|
||||
constructor(
|
||||
public callback: (...args: Args) => void,
|
||||
public wait?: number,
|
||||
) {}
|
||||
|
||||
trigger(...args: Args): void {
|
||||
this.last_args = args
|
||||
if (this.is_throttled) {
|
||||
return
|
||||
}
|
||||
this.is_throttled = true
|
||||
this.timeout_id = setTimeout(() => {
|
||||
this.callback(...(this.last_args as Args))
|
||||
this.is_throttled = false
|
||||
}, this.wait)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
clearTimeout(this.timeout_id)
|
||||
this.is_throttled = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a callback throttled using `window.requestIdleCallback()`. ([MDN reference](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback))
|
||||
*
|
||||
* The throttled callback is called on **trailing** edge.
|
||||
*
|
||||
* @param callback The callback to throttle
|
||||
* @param max_wait maximum wait time in milliseconds until the callback is called
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const idle = schedule.scheduleIdle((val: string) => console.log(val), 250)
|
||||
* idle.trigger('my-new-value')
|
||||
* idle.clear() // clears a timeout in progress
|
||||
* ```
|
||||
*/
|
||||
export function scheduleIdle<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
max_wait?: number,
|
||||
): ScheduleIdle<Args> | Throttle<Args> {
|
||||
return typeof requestIdleCallback == "function"
|
||||
? new ScheduleIdle(callback, max_wait)
|
||||
: new Throttle(callback)
|
||||
}
|
||||
|
||||
export class ScheduleIdle<Args extends unknown[]> implements Scheduler<Args> {
|
||||
is_deferred = false
|
||||
request_id: ReturnType<typeof requestIdleCallback> | undefined
|
||||
last_args: Args | undefined
|
||||
|
||||
constructor(
|
||||
public callback: (...args: Args) => void,
|
||||
public max_wait?: number,
|
||||
) {}
|
||||
|
||||
trigger(...args: Args): void {
|
||||
this.last_args = args
|
||||
if (this.is_deferred) {
|
||||
return
|
||||
}
|
||||
this.is_deferred = true
|
||||
this.request_id = requestIdleCallback(
|
||||
() => {
|
||||
this.callback(...(this.last_args as Args))
|
||||
this.is_deferred = false
|
||||
},
|
||||
{ timeout: this.max_wait },
|
||||
)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.request_id != undefined) {
|
||||
cancelIdleCallback(this.request_id)
|
||||
}
|
||||
this.is_deferred = false
|
||||
}
|
||||
}
|
||||
81
web/app/lib/utils/index.ts
Normal file
81
web/app/lib/utils/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
export const searchSafeRegExp = (inputValue: string) => {
|
||||
const escapedChars = inputValue.split("").map(escapeRegExp)
|
||||
return new RegExp(escapedChars.join(".*"), "i")
|
||||
}
|
||||
|
||||
export function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
|
||||
export const isClient = () => typeof window !== "undefined"
|
||||
|
||||
export const isServer = () => !isClient()
|
||||
|
||||
const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element
|
||||
|
||||
export function isTextInput(element: Element): boolean {
|
||||
return !!(
|
||||
element &&
|
||||
element.tagName &&
|
||||
(inputs.indexOf(element.tagName.toLowerCase()) !== -1 ||
|
||||
element.attributes.getNamedItem("role")?.value === "textbox" ||
|
||||
element.attributes.getNamedItem("contenteditable")?.value === "true")
|
||||
)
|
||||
}
|
||||
|
||||
export type HTMLAttributes = React.HTMLAttributes<HTMLElement> & {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type HTMLLikeElement = {
|
||||
tag: keyof JSX.IntrinsicElements
|
||||
attributes?: HTMLAttributes
|
||||
children?: (HTMLLikeElement | string)[]
|
||||
}
|
||||
|
||||
export const renderHTMLLikeElement = (
|
||||
element: HTMLLikeElement | string,
|
||||
): React.ReactNode => {
|
||||
if (typeof element === "string") {
|
||||
return element
|
||||
}
|
||||
|
||||
const { tag, attributes = {}, children = [] } = element
|
||||
|
||||
return React.createElement(
|
||||
tag,
|
||||
attributes,
|
||||
...children.map((child) => renderHTMLLikeElement(child)),
|
||||
)
|
||||
}
|
||||
|
||||
export function calendarFormatDate(date: Date): string {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export * from "./force-graph"
|
||||
export * from "./keyboard"
|
||||
export * from "./env"
|
||||
export * from "./slug"
|
||||
export * from "./url"
|
||||
67
web/app/lib/utils/keyboard.ts
Normal file
67
web/app/lib/utils/keyboard.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { isServer } from "."
|
||||
|
||||
interface ShortcutKeyResult {
|
||||
symbol: string
|
||||
readable: string
|
||||
}
|
||||
|
||||
export function getShortcutKey(key: string): ShortcutKeyResult {
|
||||
const lowercaseKey = key.toLowerCase()
|
||||
if (lowercaseKey === "mod") {
|
||||
return isMac()
|
||||
? { symbol: "⌘", readable: "Command" }
|
||||
: { symbol: "Ctrl", readable: "Control" }
|
||||
} else if (lowercaseKey === "alt") {
|
||||
return isMac()
|
||||
? { symbol: "⌥", readable: "Option" }
|
||||
: { symbol: "Alt", readable: "Alt" }
|
||||
} else if (lowercaseKey === "shift") {
|
||||
return isMac()
|
||||
? { symbol: "⇧", readable: "Shift" }
|
||||
: { symbol: "Shift", readable: "Shift" }
|
||||
} else {
|
||||
return { symbol: key.toUpperCase(), readable: key }
|
||||
}
|
||||
}
|
||||
|
||||
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
||||
return keys.map((key) => getShortcutKey(key))
|
||||
}
|
||||
|
||||
export function isModKey(
|
||||
event: KeyboardEvent | MouseEvent | React.KeyboardEvent,
|
||||
) {
|
||||
return isMac() ? event.metaKey : event.ctrlKey
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
if (isServer()) {
|
||||
return false
|
||||
}
|
||||
return window.navigator.platform === "MacIntel"
|
||||
}
|
||||
|
||||
export function isWindows(): boolean {
|
||||
if (isServer()) {
|
||||
return false
|
||||
}
|
||||
return window.navigator.platform === "Win32"
|
||||
}
|
||||
|
||||
let supportsPassive = false
|
||||
|
||||
try {
|
||||
const opts = Object.defineProperty({}, "passive", {
|
||||
get() {
|
||||
supportsPassive = true
|
||||
},
|
||||
})
|
||||
// @ts-expect-error ts-migrate(2769) testPassive is not a real event
|
||||
window.addEventListener("testPassive", null, opts)
|
||||
// @ts-expect-error ts-migrate(2769) testPassive is not a real event
|
||||
window.removeEventListener("testPassive", null, opts)
|
||||
} catch (e) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
export const supportsPassiveListener = supportsPassive
|
||||
43
web/app/lib/utils/schema.ts
Normal file
43
web/app/lib/utils/schema.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* This file contains custom schema definitions for Zod.
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
export const urlSchema = z
|
||||
.string()
|
||||
.min(1, { message: "URL can't be empty" })
|
||||
.refine(
|
||||
(value) => {
|
||||
const domainRegex =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
const isValidDomain = (domain: string) => {
|
||||
try {
|
||||
const url = new URL(`http://${domain}`)
|
||||
return domainRegex.test(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidDomain(value)) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (!url.protocol.match(/^https?:$/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isValidDomain(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid URL",
|
||||
},
|
||||
)
|
||||
33
web/app/lib/utils/seo.ts
Normal file
33
web/app/lib/utils/seo.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const seo = ({
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
image,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
image?: string
|
||||
keywords?: string
|
||||
}) => {
|
||||
const tags = [
|
||||
{ title },
|
||||
{ name: "description", content: description },
|
||||
{ name: "keywords", content: keywords },
|
||||
{ name: "twitter:title", content: title },
|
||||
{ name: "twitter:description", content: description },
|
||||
{ name: "twitter:creator", content: "@tannerlinsley" },
|
||||
{ name: "twitter:site", content: "@tannerlinsley" },
|
||||
{ name: "og:type", content: "website" },
|
||||
{ name: "og:title", content: title },
|
||||
{ name: "og:description", content: description },
|
||||
...(image
|
||||
? [
|
||||
{ name: "twitter:image", content: image },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "og:image", content: image },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return tags
|
||||
}
|
||||
22
web/app/lib/utils/slug.ts
Normal file
22
web/app/lib/utils/slug.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import slugify from "slugify"
|
||||
|
||||
export function generateUniqueSlug(
|
||||
title: string,
|
||||
maxLength: number = 60,
|
||||
): string {
|
||||
const baseSlug = slugify(title, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
})
|
||||
|
||||
// Web Crypto API
|
||||
const randomValues = new Uint8Array(4)
|
||||
crypto.getRandomValues(randomValues)
|
||||
const randomSuffix = Array.from(randomValues)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
|
||||
const truncatedSlug = baseSlug.slice(0, Math.min(maxLength, 75) - 9)
|
||||
|
||||
return `${truncatedSlug}-${randomSuffix}`
|
||||
}
|
||||
25
web/app/lib/utils/url.ts
Normal file
25
web/app/lib/utils/url.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function isValidUrl(string: string): boolean {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrl(text: string): boolean {
|
||||
const pattern: RegExp =
|
||||
/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/
|
||||
return pattern.test(text)
|
||||
}
|
||||
|
||||
export function ensureUrlProtocol(
|
||||
url: string,
|
||||
defaultProtocol: string = "https://",
|
||||
): string {
|
||||
if (url.match(/^[a-zA-Z]+:\/\//)) {
|
||||
return url
|
||||
}
|
||||
|
||||
return `${defaultProtocol}${url.startsWith("//") ? url.slice(2) : url}`
|
||||
}
|
||||
Reference in New Issue
Block a user