mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix: conflict
This commit is contained in:
@@ -11,11 +11,11 @@ export function DeepLinkProvider({ children }: DeepLinkProviderProps) {
|
||||
const eventHandlers: { [key: string]: (event: Event) => void } = {
|
||||
click: (event: Event) => {
|
||||
const e = event as MouseEvent
|
||||
console.log("Click event:", { x: e.clientX, y: e.clientY })
|
||||
// console.log("Click event:", { x: e.clientX, y: e.clientY })
|
||||
},
|
||||
keydown: (event: Event) => {
|
||||
const e = event as KeyboardEvent
|
||||
console.log("Keydown event:", { key: e.key, code: e.code })
|
||||
// console.log("Keydown event:", { key: e.key, code: e.code })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,105 +2,56 @@
|
||||
|
||||
import { createJazzReactApp } from "jazz-react"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { useClerk } from "@clerk/nextjs"
|
||||
import { createContext, useMemo, useState } from "react"
|
||||
import { AuthMethodCtx } from "jazz-react"
|
||||
import { useAuth, useClerk } from "@clerk/nextjs"
|
||||
import { useJazzClerkAuth } from "jazz-react-auth-clerk"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
const Jazz = createJazzReactApp({
|
||||
AccountSchema: LaAccount
|
||||
})
|
||||
|
||||
export const { useAccount, useCoState, useAcceptInvite } = Jazz
|
||||
export const { useAccount, useAccountOrGuest, useCoState, useAcceptInvite } = Jazz
|
||||
|
||||
export function JazzProvider({ children }: { children: React.ReactNode }) {
|
||||
return <Jazz.Provider peer="wss://mesh.jazz.tools/?key=example@gmail.com">{children}</Jazz.Provider>
|
||||
function assertPeerUrl(url: string | undefined): asserts url is `wss://${string}` | `ws://${string}` {
|
||||
if (!url) {
|
||||
throw new Error("NEXT_PUBLIC_JAZZ_PEER_URL is not defined")
|
||||
}
|
||||
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
|
||||
throw new Error("NEXT_PUBLIC_JAZZ_PEER_URL must start with wss:// or ws://")
|
||||
}
|
||||
}
|
||||
|
||||
export const JazzClerkAuthCtx = createContext<{
|
||||
errors: string[]
|
||||
}>({
|
||||
errors: []
|
||||
})
|
||||
const rawUrl = process.env.NEXT_PUBLIC_JAZZ_PEER_URL
|
||||
assertPeerUrl(rawUrl)
|
||||
const JAZZ_PEER_URL = rawUrl
|
||||
|
||||
export function JazzClerkAuth({ children }: { children: React.ReactNode }) {
|
||||
interface ChildrenProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function JazzAndAuth({ children }: ChildrenProps) {
|
||||
const pathname = usePathname()
|
||||
return pathname === "/" ? <JazzGuest>{children}</JazzGuest> : <JazzAuth>{children}</JazzAuth>
|
||||
}
|
||||
|
||||
export function JazzAuth({ children }: ChildrenProps) {
|
||||
const clerk = useClerk()
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const { isLoaded } = useAuth()
|
||||
const [authMethod] = useJazzClerkAuth(clerk)
|
||||
|
||||
const authMethod = useMemo(() => {
|
||||
return new BrowserClerkAuth(
|
||||
{
|
||||
onError: error => {
|
||||
void clerk.signOut()
|
||||
setErrors(errors => [...errors, error.toString()])
|
||||
}
|
||||
},
|
||||
clerk
|
||||
)
|
||||
}, [clerk])
|
||||
if (!isLoaded) return null
|
||||
|
||||
return (
|
||||
<JazzClerkAuthCtx.Provider value={{ errors }}>
|
||||
<AuthMethodCtx.Provider value={authMethod}>{children}</AuthMethodCtx.Provider>
|
||||
</JazzClerkAuthCtx.Provider>
|
||||
<Jazz.Provider auth={authMethod || "guest"} peer={JAZZ_PEER_URL}>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools"
|
||||
import type { LoadedClerk } from "@clerk/types"
|
||||
import { AgentSecret } from "cojson"
|
||||
|
||||
export class BrowserClerkAuth implements AuthMethod {
|
||||
constructor(
|
||||
public driver: BrowserClerkAuth.Driver,
|
||||
private readonly clerkClient: LoadedClerk
|
||||
) {}
|
||||
|
||||
async start(): Promise<AuthResult> {
|
||||
if (this.clerkClient.user) {
|
||||
const storedCredentials = this.clerkClient.user.unsafeMetadata
|
||||
if (storedCredentials.jazzAccountID) {
|
||||
if (!storedCredentials.jazzAccountSecret) {
|
||||
throw new Error("No secret for existing user")
|
||||
}
|
||||
return {
|
||||
type: "existing",
|
||||
credentials: {
|
||||
accountID: storedCredentials.jazzAccountID as ID<Account>,
|
||||
secret: storedCredentials.jazzAccountSecret as AgentSecret
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: (error: string | Error) => {
|
||||
this.driver.onError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "new",
|
||||
creationProps: {
|
||||
name: this.clerkClient.user.fullName || this.clerkClient.user.username || this.clerkClient.user.id
|
||||
},
|
||||
saveCredentials: async (credentials: { accountID: ID<Account>; secret: AgentSecret }) => {
|
||||
await this.clerkClient.user?.update({
|
||||
unsafeMetadata: {
|
||||
jazzAccountID: credentials.accountID,
|
||||
jazzAccountSecret: credentials.secret
|
||||
}
|
||||
})
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: (error: string | Error) => {
|
||||
this.driver.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error("Not signed in")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace BrowserClerkAuth {
|
||||
export interface Driver {
|
||||
onError: (error: string | Error) => void
|
||||
}
|
||||
export function JazzGuest({ children }: ChildrenProps) {
|
||||
return (
|
||||
<Jazz.Provider auth="guest" peer={JAZZ_PEER_URL}>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,67 +8,81 @@
|
||||
// open issue about it: https://github.com/gardencmp/jazz/issues/44
|
||||
// TODO: figure out how to do default values, e.g. `GlobalLink.protocol` should have default value `https` so we don't have to supply it every time in code..
|
||||
// TODO: can jazz support vector fields? e.g. `GlobalLinkAiSummary.vectorContent`, would be nice to store website content as vector for semantic search
|
||||
import { CoMap, co, Account, Profile } from "jazz-tools"
|
||||
import { PersonalPageLists } from "./personal-page"
|
||||
import { PersonalLinkLists } from "./personal-link"
|
||||
import { ListOfTopics } from "./master/topic"
|
||||
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 './tasks'
|
||||
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
|
||||
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)
|
||||
personalLinks = co.ref(PersonalLinkLists)
|
||||
personalPages = co.ref(PersonalPageLists)
|
||||
|
||||
topicsWantToLearn = co.ref(ListOfTopics)
|
||||
topicsLearning = co.ref(ListOfTopics)
|
||||
topicsLearned = co.ref(ListOfTopics)
|
||||
topicsWantToLearn = co.ref(ListOfTopics)
|
||||
topicsLearning = co.ref(ListOfTopics)
|
||||
topicsLearned = co.ref(ListOfTopics)
|
||||
|
||||
// TODO: maybe should be in another place?
|
||||
connectedFolderPath = co.optional.string
|
||||
tasks = co.ref(ListOfTasks)
|
||||
journalEntries = co.ref(JournalEntryLists)
|
||||
|
||||
// TODO: maybe should be in another place?
|
||||
connectedFolderPath = co.optional.string
|
||||
}
|
||||
|
||||
export class LaAccount extends Account {
|
||||
profile = co.ref(Profile)
|
||||
root = co.ref(UserRoot)
|
||||
profile = co.ref(Profile)
|
||||
root = co.ref(UserRoot)
|
||||
|
||||
migrate(this: LaAccount, creationProps?: { name: 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)
|
||||
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)
|
||||
|
||||
console.log("In migration", this._refs.root, creationProps)
|
||||
if (!this._refs.root && creationProps) {
|
||||
this.root = UserRoot.create(
|
||||
{
|
||||
name: creationProps.name,
|
||||
username: creationProps.name,
|
||||
avatar: creationProps.avatarUrl || '',
|
||||
website: '',
|
||||
bio: '',
|
||||
is_public: false,
|
||||
|
||||
if (!this._refs.root && creationProps) {
|
||||
this.root = UserRoot.create(
|
||||
{
|
||||
name: creationProps.name,
|
||||
username: creationProps.name,
|
||||
avatar: "",
|
||||
website: "",
|
||||
bio: "",
|
||||
is_public: false,
|
||||
connectedFolderPath: '',
|
||||
|
||||
connectedFolderPath: "",
|
||||
personalLinks: PersonalLinkLists.create([], { owner: this }),
|
||||
personalPages: PersonalPageLists.create([], { owner: this }),
|
||||
|
||||
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 }),
|
||||
|
||||
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
|
||||
topicsLearning: ListOfTopics.create([], { owner: this }),
|
||||
topicsLearned: ListOfTopics.create([], { owner: this })
|
||||
},
|
||||
{ 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"
|
||||
export * from './master/topic'
|
||||
export * from './personal-link'
|
||||
export * from './personal-page'
|
||||
|
||||
11
web/lib/schema/journal.ts
Normal file
11
web/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)) {}
|
||||
11
web/lib/schema/tasks.ts
Normal file
11
web/lib/schema/tasks.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
}
|
||||
|
||||
export class ListOfTasks extends CoList.Of(co.ref(Task)) {}
|
||||
13
web/lib/utils/auth-procedure.ts
Normal file
13
web/lib/utils/auth-procedure.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { currentUser } from "@clerk/nextjs/server"
|
||||
import { createServerActionProcedure, ZSAError } from "zsa"
|
||||
|
||||
export const authedProcedure = createServerActionProcedure()
|
||||
.handler(async () => {
|
||||
try {
|
||||
const clerkUser = await currentUser()
|
||||
return { clerkUser }
|
||||
} catch {
|
||||
throw new ZSAError("NOT_AUTHORIZED", "User not authenticated")
|
||||
}
|
||||
})
|
||||
.createServerAction()
|
||||
45
web/lib/utils/htmlLikeElementUtil.test.tsx
Normal file
45
web/lib/utils/htmlLikeElementUtil.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import { render } from "@testing-library/react"
|
||||
import { HTMLLikeElement, renderHTMLLikeElement } from "./htmlLikeElementUtil"
|
||||
|
||||
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = ({ content }) => {
|
||||
return <>{renderHTMLLikeElement(content)}</>
|
||||
}
|
||||
|
||||
describe("HTML-like Element Utility", () => {
|
||||
test("HTMLLikeRenderer renders simple string content", () => {
|
||||
const { getByText } = render(<HTMLLikeRenderer content="Hello, World!" />)
|
||||
expect(getByText("Hello, World!")).toBeTruthy()
|
||||
})
|
||||
|
||||
test("HTMLLikeRenderer renders HTML-like structure", () => {
|
||||
const content: HTMLLikeElement = {
|
||||
tag: "div",
|
||||
attributes: { className: "test-class" },
|
||||
children: ["Hello, ", { tag: "strong", children: ["World"] }, "!"]
|
||||
}
|
||||
const { container, getByText } = render(<HTMLLikeRenderer content={content} />)
|
||||
expect(container.firstChild).toHaveProperty("className", "test-class")
|
||||
const strongElement = getByText("World")
|
||||
expect(strongElement.tagName.toLowerCase()).toBe("strong")
|
||||
})
|
||||
|
||||
test("HTMLLikeRenderer handles multiple attributes", () => {
|
||||
const content: HTMLLikeElement = {
|
||||
tag: "div",
|
||||
attributes: {
|
||||
className: "test-class",
|
||||
id: "test-id",
|
||||
"data-testid": "custom-element",
|
||||
style: { color: "red", fontSize: "16px" }
|
||||
},
|
||||
children: ["Test Content"]
|
||||
}
|
||||
const { getByTestId } = render(<HTMLLikeRenderer content={content} />)
|
||||
const element = getByTestId("custom-element")
|
||||
expect(element.className).toBe("test-class")
|
||||
expect(element.id).toBe("test-id")
|
||||
expect(element.style.color).toBe("red")
|
||||
expect(element.style.fontSize).toBe("16px")
|
||||
})
|
||||
})
|
||||
21
web/lib/utils/htmlLikeElementUtil.ts
Normal file
21
web/lib/utils/htmlLikeElementUtil.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
|
||||
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)))
|
||||
}
|
||||
@@ -9,6 +9,52 @@ export const randomId = () => {
|
||||
return Math.random().toString(36).substring(7)
|
||||
}
|
||||
|
||||
export const toTitleCase = (str: string): string => {
|
||||
return str
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, str => str.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 function calendarFormatDate(date: Date): string {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})
|
||||
}
|
||||
|
||||
export * from "./urls"
|
||||
export * from "./slug"
|
||||
export * from "./keyboard"
|
||||
export * from "./htmlLikeElementUtil"
|
||||
|
||||
@@ -1,47 +1,4 @@
|
||||
let isMac: boolean | undefined
|
||||
|
||||
interface Navigator {
|
||||
userAgentData?: {
|
||||
brands: { brand: string; version: string }[]
|
||||
mobile: boolean
|
||||
platform: string
|
||||
getHighEntropyValues: (hints: string[]) => Promise<{
|
||||
platform: string
|
||||
platformVersion: string
|
||||
uaFullVersion: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatform(): string {
|
||||
const nav = navigator as Navigator
|
||||
|
||||
if (nav.userAgentData) {
|
||||
if (nav.userAgentData.platform) {
|
||||
return nav.userAgentData.platform
|
||||
}
|
||||
|
||||
nav.userAgentData.getHighEntropyValues(["platform"]).then(highEntropyValues => {
|
||||
if (highEntropyValues.platform) {
|
||||
return highEntropyValues.platform
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof navigator.platform === "string") {
|
||||
return navigator.platform
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function isMacOS() {
|
||||
if (isMac === undefined) {
|
||||
isMac = getPlatform().toLowerCase().includes("mac")
|
||||
}
|
||||
|
||||
return isMac
|
||||
}
|
||||
const SSR = typeof window === "undefined"
|
||||
|
||||
interface ShortcutKeyResult {
|
||||
symbol: string
|
||||
@@ -51,15 +8,11 @@ interface ShortcutKeyResult {
|
||||
export function getShortcutKey(key: string): ShortcutKeyResult {
|
||||
const lowercaseKey = key.toLowerCase()
|
||||
if (lowercaseKey === "mod") {
|
||||
return isMacOS() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" }
|
||||
return isMac() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" }
|
||||
} else if (lowercaseKey === "alt") {
|
||||
return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
|
||||
return isMac() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
|
||||
} else if (lowercaseKey === "shift") {
|
||||
return { symbol: "⇧", readable: "Shift" }
|
||||
} else if (lowercaseKey === "control") {
|
||||
return { symbol: "⌃", readable: "Control" }
|
||||
} else if (lowercaseKey === "windows" && !isMacOS()) {
|
||||
return { symbol: "Win", readable: "Windows" }
|
||||
return isMac() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
|
||||
} else {
|
||||
return { symbol: key.toUpperCase(), readable: key }
|
||||
}
|
||||
@@ -69,20 +22,38 @@ export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
||||
return keys.map(key => getShortcutKey(key))
|
||||
}
|
||||
|
||||
export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] {
|
||||
if (shortcutName === "expandToolbar") {
|
||||
return isMacOS()
|
||||
? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")]
|
||||
: [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")]
|
||||
export function isModKey(event: KeyboardEvent | MouseEvent | React.KeyboardEvent) {
|
||||
return isMac() ? event.metaKey : event.ctrlKey
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
if (SSR) {
|
||||
return false
|
||||
}
|
||||
|
||||
return []
|
||||
return window.navigator.platform === "MacIntel"
|
||||
}
|
||||
|
||||
export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string {
|
||||
return shortcutKeys.map(key => key.symbol).join("")
|
||||
export function isWindows(): boolean {
|
||||
if (SSR) {
|
||||
return false
|
||||
}
|
||||
return window.navigator.platform === "Win32"
|
||||
}
|
||||
|
||||
export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string {
|
||||
return shortcutKeys.map(key => key.readable).join(" + ")
|
||||
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
|
||||
|
||||
29
web/lib/utils/slug.test.ts
Normal file
29
web/lib/utils/slug.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { generateUniqueSlug } from "./slug"
|
||||
|
||||
describe("generateUniqueSlug", () => {
|
||||
it("should generate a slug with the correct format", () => {
|
||||
const title = "This is a test title"
|
||||
const slug = generateUniqueSlug(title)
|
||||
expect(slug).toMatch(/^this-is-a-test-title-[a-f0-9]{8}$/)
|
||||
})
|
||||
|
||||
it("should respect the maxLength parameter", () => {
|
||||
const title = "This is a very long title that should be truncated"
|
||||
const maxLength = 30
|
||||
const slug = generateUniqueSlug(title, maxLength)
|
||||
expect(slug.length).toBe(maxLength)
|
||||
})
|
||||
|
||||
it("should generate different slugs for the same title", () => {
|
||||
const title = "Same Title"
|
||||
const slug1 = generateUniqueSlug(title)
|
||||
const slug2 = generateUniqueSlug(title)
|
||||
expect(slug1).not.toBe(slug2)
|
||||
})
|
||||
|
||||
it("should handle empty strings", () => {
|
||||
const title = ""
|
||||
const slug = generateUniqueSlug(title)
|
||||
expect(slug).toMatch(/^-[a-f0-9]{8}$/)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,14 @@
|
||||
import slugify from "slugify"
|
||||
import crypto from "crypto"
|
||||
|
||||
type SlugLikeProperty = string | undefined
|
||||
export function generateUniqueSlug(title: string, maxLength: number = 60): string {
|
||||
const baseSlug = slugify(title, {
|
||||
lower: true,
|
||||
strict: true
|
||||
})
|
||||
const randomSuffix = crypto.randomBytes(4).toString("hex")
|
||||
|
||||
interface Data {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function generateUniqueSlug(
|
||||
existingItems: Data[],
|
||||
title: string,
|
||||
slugProperty: string = "slug",
|
||||
maxLength: number = 50
|
||||
): string {
|
||||
const baseSlug = slugify(title, { lower: true, strict: true })
|
||||
let uniqueSlug = baseSlug.slice(0, maxLength)
|
||||
let num = 1
|
||||
|
||||
if (!existingItems || existingItems.length === 0) {
|
||||
return uniqueSlug
|
||||
}
|
||||
|
||||
const isSlugTaken = (slug: string) =>
|
||||
existingItems.some(item => {
|
||||
const itemSlug = item[slugProperty] as SlugLikeProperty
|
||||
return itemSlug === slug
|
||||
})
|
||||
|
||||
while (isSlugTaken(uniqueSlug)) {
|
||||
const suffix = `-${num}`
|
||||
uniqueSlug = `${baseSlug.slice(0, maxLength - suffix.length)}${suffix}`
|
||||
num++
|
||||
}
|
||||
|
||||
return uniqueSlug
|
||||
const truncatedSlug = baseSlug.slice(0, Math.min(maxLength, 75) - 9)
|
||||
|
||||
return `${truncatedSlug}-${randomSuffix}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user