diff --git a/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png b/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png index ffba6da9e..a2a2ee6c6 100644 Binary files a/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png and b/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png differ diff --git a/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png b/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png index 9bead3061..799bf3564 100644 Binary files a/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png and b/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png differ diff --git a/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png b/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png index ffba6da9e..a2a2ee6c6 100644 Binary files a/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png and b/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png differ diff --git a/jest.config.js b/jest.config.js index eb5fddf20..4de3834f4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,7 @@ module.exports = { "^layouts/(.*)": `/src/layouts/$1`, "^core/(.*)": `/src/core/$1`, "^components/(.*)": `/src/components/$1`, + "^libs/(.*)": `/src/libs/$1`, "^providers/(.*)": `/src/providers/$1`, "^containers/(.*)": `/src/containers/$1`, "^design-system/(.*)": `/src/design-system/$1`, diff --git a/meta.ts b/meta.ts index 961315862..cda83df13 100644 --- a/meta.ts +++ b/meta.ts @@ -20,6 +20,9 @@ export const meta = { ytVideoTutorialUrl: `https://www.youtube.com/watch?v=t3Ve0em65rY`, mdCheatsheet: `https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet`, routes: { + accessGroups: { + management: `/access-groups/`, + }, mindmaps: { mindmap: `/mindmap/`, creator: `/mindmap-creator/`, diff --git a/seo-plugins.ts b/seo-plugins.ts index 575158524..7188f0952 100644 --- a/seo-plugins.ts +++ b/seo-plugins.ts @@ -30,6 +30,7 @@ const disallowedPaths = [ meta.routes.creator.preview, meta.routes.sandbox, meta.routes.mindmaps.preview, + meta.routes.accessGroups.management, legacyRoutes.documents.preview, legacyRoutes.documents.browse, ]; diff --git a/src/acts/update-mindmap-visibility.act.ts b/src/acts/update-mindmap-visibility.act.ts index d04ce4aeb..f869e6934 100644 --- a/src/acts/update-mindmap-visibility.act.ts +++ b/src/acts/update-mindmap-visibility.act.ts @@ -1,5 +1,6 @@ import { getAPI, parseError, setCache } from "api-4markdown"; -import type { MindmapDto } from "api-4markdown-contracts"; +import type { AccessGroupId, MindmapDto } from "api-4markdown-contracts"; +import { AsyncResult } from "development-kit/utility-types"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { readyMindmapsSelector, @@ -8,7 +9,8 @@ import { const updateMindmapVisibilityAct = async ( visibility: MindmapDto["visibility"], -): Promise => { + sharedForGroups?: AccessGroupId[], +): AsyncResult => { try { useMindmapCreatorState.set({ operation: { is: `busy` } }); @@ -17,17 +19,27 @@ const updateMindmapVisibilityAct = async ( const activeMindmap = safeActiveMindmapSelector(mindmapCreatorState); const yourMindmaps = readyMindmapsSelector(mindmapCreatorState.mindmaps); - const response = await getAPI().call(`updateMindmapVisibility`)({ - mdate: activeMindmap.mdate, - id: activeMindmap.id, - visibility, - }); + const response = await getAPI().call(`updateMindmapVisibility`)( + Array.isArray(sharedForGroups) + ? { + mdate: activeMindmap.mdate, + id: activeMindmap.id, + visibility, + sharedForGroups, + } + : { + mdate: activeMindmap.mdate, + id: activeMindmap.id, + visibility, + }, + ); const newMindmaps = yourMindmaps.data.map((mindmap) => mindmap.id === activeMindmap.id ? { ...mindmap, mdate: response.mdate, + sharedForGroups, visibility, } : mindmap, @@ -45,10 +57,14 @@ const updateMindmapVisibilityAct = async ( mindmaps: newMindmaps, mindmapsCount: newMindmaps.length, }); + + return { is: "ok" }; } catch (error: unknown) { + const parsed = parseError(error); useMindmapCreatorState.set({ - operation: { is: `fail`, error: parseError(error) }, + operation: { is: `fail`, error: parsed }, }); + return { is: `fail`, error: parsed }; } }; diff --git a/src/api-4markdown-contracts/atoms.ts b/src/api-4markdown-contracts/atoms.ts index 2386c6afa..b7bee7d7f 100644 --- a/src/api-4markdown-contracts/atoms.ts +++ b/src/api-4markdown-contracts/atoms.ts @@ -5,6 +5,8 @@ type Id = string; type Name = string; type MarkdownCode = string; type Date = string; +type UTCDate = Brand; +type Etag = Brand; type Tags = string[]; type Path = string; type MarkdownContent = string; @@ -19,13 +21,24 @@ type DocumentId = Brand; type MindmapNodeId = Brand; type MindmapId = Brand; +type AccessGroupId = Brand; + type ResourceId = DocumentId | MindmapNodeId | MindmapId; +const RESOURCE_VISIBILITIES = [ + "private", + "public", + "permanent", + "manual", +] as const; + +type ResourceVisibility = (typeof RESOURCE_VISIBILITIES)[number]; + const RESOURCE_TYPES = ["document", "mindmap", "mindmap-node"] as const; type ResourceType = (typeof RESOURCE_TYPES)[number]; -export { RESOURCE_TYPES }; +export { RESOURCE_TYPES, RESOURCE_VISIBILITIES }; export type { Id, Name, @@ -45,4 +58,8 @@ export type { MindmapId, MindmapNodeId, ResourceType, + AccessGroupId, + Etag, + UTCDate, + ResourceVisibility, }; diff --git a/src/api-4markdown-contracts/contracts/index.ts b/src/api-4markdown-contracts/contracts/index.ts index 679e19cdf..2286caa02 100644 --- a/src/api-4markdown-contracts/contracts/index.ts +++ b/src/api-4markdown-contracts/contracts/index.ts @@ -1,8 +1,10 @@ import { Brand, type Prettify } from "development-kit/utility-types"; import type { + AccessGroupId, Base64, Date, DocumentId, + Etag, Id, MindmapId, MindmapNodeId, @@ -27,6 +29,7 @@ import type { CommentDto, ResourceCompletionDto, } from "../dtos"; +import { AccessGroupDto } from "../dtos/access-group.dto"; // @TODO[PRIO=1]: [Add better error handling and throwing custom errors]. type Contract = { @@ -111,7 +114,7 @@ type DeleteMindmapContract = Contract< type UpdateMindmapVisibilityContract = Contract< `updateMindmapVisibility`, Pick, - Pick + Pick >; type UpdateMindmapContract = Contract< @@ -269,6 +272,78 @@ type SetUserResourceCompletionContract = Contract< } >; +type GetYourAccessGroupsContract = Contract< + "getYourAccessGroups", + { + hasMore: boolean; + nextCursor: Pick | null; + accessGroups: AccessGroupDto[]; + }, + { + limit: number | null; + cursor: Pick | null; + } +>; + +type CreateAccessGroupContract = Contract< + "createAccessGroup", + AccessGroupDto, + { name: string; description: string | null } +>; + +type EditAccessGroupContract = Contract< + "editAccessGroup", + Pick, + { + name: string; + etag: Etag; + description: string | null; + id: AccessGroupId; + } +>; + +type GetAccessGroupContract = Contract< + "getAccessGroup", + Pick< + AccessGroupDto, + "cdate" | "description" | "etag" | "id" | "mdate" | "name" + > & { members: UserProfileDto[] }, + { id: AccessGroupId } +>; + +type FindUserProfilesContract = Contract< + "findUserProfiles", + { + hasMore: boolean; + userProfiles: UserProfileDto[]; + }, + { query: string; by: "displayName" | "id"; limit?: number } +>; + +type AddAccessGroupMemberContract = Contract< + "addAccessGroupMember", + Pick< + AccessGroupDto, + "mdate" | "etag" | "id" | "cdate" | "description" | "name" + > & { member: UserProfileDto }, + { id: AccessGroupId; memberProfileId: UserProfileId; etag: Etag } +>; + +type RemoveAccessGroupMemberContract = Contract< + "removeAccessGroupMember", + Pick< + AccessGroupDto, + "mdate" | "etag" | "id" | "cdate" | "description" | "name" + > & { member: UserProfileDto }, + { id: AccessGroupId; memberProfileId: UserProfileId; etag: Etag } +>; + +type RemoveAccessGroupContract = Contract< + "removeAccessGroup", + null, + { id: AccessGroupId } +>; + type API4MarkdownContracts = | CreateMindmapContract | GetYourDocumentsContract @@ -298,7 +373,15 @@ type API4MarkdownContracts = | GetUserProfileContract | AddUserProfileCommentContract | GetUserResourceCompletionsContract - | SetUserResourceCompletionContract; + | SetUserResourceCompletionContract + | GetYourAccessGroupsContract + | CreateAccessGroupContract + | EditAccessGroupContract + | GetAccessGroupContract + | FindUserProfilesContract + | AddAccessGroupMemberContract + | RemoveAccessGroupMemberContract + | RemoveAccessGroupContract; type API4MarkdownContractKey = API4MarkdownContracts["key"]; type API4MarkdownDto = Extract< diff --git a/src/api-4markdown-contracts/dtos/access-group.dto.ts b/src/api-4markdown-contracts/dtos/access-group.dto.ts new file mode 100644 index 000000000..e84fe18c9 --- /dev/null +++ b/src/api-4markdown-contracts/dtos/access-group.dto.ts @@ -0,0 +1,13 @@ +import { AccessGroupId, Etag, UserProfileId, UTCDate } from "../atoms"; + +type AccessGroupDto = { + id: AccessGroupId; + cdate: UTCDate; + etag: Etag; + mdate: UTCDate; + name: string; + description: string | null; + members: UserProfileId[]; +}; + +export type { AccessGroupDto }; diff --git a/src/api-4markdown-contracts/dtos/document.dto.ts b/src/api-4markdown-contracts/dtos/document.dto.ts index b34efba0b..aaca36456 100644 --- a/src/api-4markdown-contracts/dtos/document.dto.ts +++ b/src/api-4markdown-contracts/dtos/document.dto.ts @@ -6,6 +6,7 @@ import type { Description, Tags, Path, + AccessGroupId, } from "../atoms"; import type { RatingDto } from "./rating.dto"; import type { UserProfileDto } from "./user-profile.dto"; @@ -16,6 +17,7 @@ type Base = { code: MarkdownCode; mdate: Date; cdate: Date; + sharedForGroups?: AccessGroupId[]; path: Path; }; // @TODO[PRIO=2]: [Re-design contracts to be atomic, instead of creating huge shared objects...]. @@ -37,14 +39,22 @@ type PermanentDocumentDto = Base & { rating: RatingDto; }; +type ManualDocumentDto = Base & { + visibility: "manual"; + author: UserProfileDto | null; + rating: RatingDto; +}; + type DocumentDto = | PrivateDocumentDto | PublicDocumentDto - | PermanentDocumentDto; + | PermanentDocumentDto + | ManualDocumentDto; export type { PrivateDocumentDto, PublicDocumentDto, PermanentDocumentDto, DocumentDto, + ManualDocumentDto, }; diff --git a/src/api-4markdown-contracts/dtos/index.ts b/src/api-4markdown-contracts/dtos/index.ts index 227523f66..af579ce37 100644 --- a/src/api-4markdown-contracts/dtos/index.ts +++ b/src/api-4markdown-contracts/dtos/index.ts @@ -8,3 +8,4 @@ export * from "./rewrite-assistant.dto"; export * from "./your-account.dto"; export * from "./comment.dto"; export * from "./resource-completion.dto"; +export * from "./access-group.dto"; diff --git a/src/api-4markdown-contracts/dtos/mindmap.dto.ts b/src/api-4markdown-contracts/dtos/mindmap.dto.ts index e9b47729c..38142b4aa 100644 --- a/src/api-4markdown-contracts/dtos/mindmap.dto.ts +++ b/src/api-4markdown-contracts/dtos/mindmap.dto.ts @@ -1,8 +1,10 @@ import type { + AccessGroupId, Date, Id, MarkdownContent, Path, + ResourceVisibility, Tags, Url, } from "api-4markdown-contracts"; @@ -53,11 +55,12 @@ type MindmapDto = { cdate: Date; mdate: Date; name: string; + sharedForGroups?: AccessGroupId[]; orientation: `x` | `y`; path: Path; nodes: MindmapNode[]; edges: MindmapEdge[]; - visibility: `private` | `public` | `permanent`; + visibility: ResourceVisibility; description: string | null; tags: Tags | null; }; diff --git a/src/components/meta.tsx b/src/components/meta.tsx index e719efe75..56c707ea4 100644 --- a/src/components/meta.tsx +++ b/src/components/meta.tsx @@ -49,7 +49,7 @@ const Meta = ({ {/* Ogs */} - + {/* Other */} diff --git a/src/components/resource-visibility-tabs.tsx b/src/components/resource-visibility-tabs.tsx new file mode 100644 index 000000000..53063ddd1 --- /dev/null +++ b/src/components/resource-visibility-tabs.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Tabs2 } from "design-system/tabs-2"; +import { VisibilityIcon } from "./visibility-icon"; +import { + RESOURCE_VISIBILITIES, + ResourceVisibility, +} from "api-4markdown-contracts"; + +type ResourceVisibilityTabsProps = { + className?: string; + disabled: boolean; + visibility: ResourceVisibility; + title: (visibility: ResourceVisibility) => string; + onChange: (visibility: ResourceVisibility) => void; +}; + +const ResourceVisibilityTabs = ({ + className, + visibility, + title, + onChange, + disabled, +}: ResourceVisibilityTabsProps) => { + return ( + + {RESOURCE_VISIBILITIES.map((type) => ( + onChange(type)} + disabled={disabled} + > + + {type} + + ))} + + ); +}; + +export { ResourceVisibilityTabs }; diff --git a/src/components/user-popover-content.tsx b/src/components/user-popover-content.tsx index 08c9c3f69..8ff99425a 100644 --- a/src/components/user-popover-content.tsx +++ b/src/components/user-popover-content.tsx @@ -1,10 +1,10 @@ import React from "react"; import { Button } from "design-system/button"; import { + BiArrowToRight, BiPencil, BiRefresh, BiSolidUserDetail, - BiWorld, } from "react-icons/bi"; import { useConfirm } from "development-kit/use-confirm"; import { Modal } from "design-system/modal"; @@ -74,6 +74,27 @@ const UserPopoverContent = ({ onClose }: { onClose(): void }) => { {yourUserProfile.is === `ok` && ( <> +
+
+ +
+
Your Access Groups
+

+ Engage your audience and assign them to access groups to share + specific materials. +

+
+ {yourUserProfile.user?.displayName && yourUserProfile.user?.bio ? ( <>
; + +const VisibilityIcon = ({ + visibility, + ...props +}: IconBaseProps & { visibility: ResourceVisibility }) => { + const Icon = ICONS_MAP[visibility]; + + return ; +}; + +export { VisibilityIcon }; diff --git a/src/containers/creation-link-2.container.tsx b/src/containers/creation-link-2.container.tsx index 6b2b8ea37..5d4a120a3 100644 --- a/src/containers/creation-link-2.container.tsx +++ b/src/containers/creation-link-2.container.tsx @@ -9,9 +9,11 @@ import { docStoreSelectors } from "store/doc/doc.store"; import { useSimpleFeature } from "@greenonsoftware/react-kit"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { activeMindmapSelector } from "store/mindmap-creator/selectors"; +import { useAuthStore } from "store/auth/auth.store"; // @TODO[PRIO=2]: [Remove duplication with creation-link.container.tsx]. const CreationLinkContainer2 = () => { const menu = useSimpleFeature(); + const auth = useAuthStore(); const docStore = docStoreSelectors.state(); const activeMindmap = useMindmapCreatorState(activeMindmapSelector); @@ -97,6 +99,19 @@ const CreationLinkContainer2 = () => { )} + {auth.is === `authorized` && ( +
  • + +
    Access Groups
    +

    + Create access groups and manage access to your content +

    + +
  • + )}
    ); diff --git a/src/containers/creation-link.container.tsx b/src/containers/creation-link.container.tsx index d5ac5fb95..645a8c4db 100644 --- a/src/containers/creation-link.container.tsx +++ b/src/containers/creation-link.container.tsx @@ -9,9 +9,11 @@ import { docStoreSelectors } from "store/doc/doc.store"; import { useSimpleFeature } from "@greenonsoftware/react-kit"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { activeMindmapSelector } from "store/mindmap-creator/selectors"; +import { useAuthStore } from "store/auth/auth.store"; const CreationLinkContainer = () => { const menu = useSimpleFeature(); + const auth = useAuthStore(); const docStore = docStoreSelectors.state(); const activeMindmap = useMindmapCreatorState(activeMindmapSelector); @@ -100,6 +102,19 @@ const CreationLinkContainer = () => { )} + {auth.is === `authorized` && ( +
  • + +
    Access Groups
    +

    + Create access groups and manage access to your content +

    + +
  • + )} diff --git a/src/core/use-mutation.ts b/src/core/use-mutation.ts new file mode 100644 index 000000000..476262532 --- /dev/null +++ b/src/core/use-mutation.ts @@ -0,0 +1,94 @@ +import { parseError } from "api-4markdown"; +import { ParsedError } from "api-4markdown-contracts"; +import React from "react"; + +type RawError = unknown; +type Idle = { is: "idle" }; +type Busy = { is: "busy" }; +type Ok = { is: "ok"; data: TData }; +type Fail = { is: "fail"; error: ParsedError; rawError: RawError }; +type MutationState = Idle | Busy | Ok | Fail; +type Handler = (signal: AbortSignal) => Promise; + +type MutationConfig = { + handler?: Handler; + onBusy?: () => void; + onOk?: (data: TData) => void; + onFail?: (error: ParsedError, rawError: RawError) => void; +}; + +const initialState: MutationState = { is: "idle" }; + +const useMutation = (config: MutationConfig = {}) => { + const { onBusy, onOk, onFail, handler } = config; + const [state, setState] = React.useState>(initialState); + + const configRef = React.useRef>(config); + const abortRef = React.useRef(); + + React.useEffect(() => { + configRef.current = config; + }, [onBusy, onOk, onFail, handler]); + + React.useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const start = React.useCallback( + async (handler?: Handler): Promise => { + abortRef.current?.abort(); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const finalHandler = handler || configRef.current.handler; + + if (!finalHandler) { + throw new Error("Handler is required"); + } + + setState({ is: "busy" }); + configRef.current.onBusy?.(); + const data = await finalHandler(controller.signal); + + if (controller.signal.aborted) return; + + setState({ is: "ok", data }); + configRef.current.onOk?.(data); + } catch (error) { + if (controller.signal.aborted) return; + + const parsedError = parseError(error); + setState({ is: "fail", error: parsedError, rawError: error }); + configRef.current.onFail?.(parsedError, error); + } + }, + [setState], + ); + + const abort = React.useCallback( + (reset = false) => { + abortRef.current?.abort(); + + if (reset) { + setState(initialState); + } + }, + [setState], + ); + + const setData = React.useCallback( + (data: TData) => { + setState({ is: "ok", data }); + }, + [setState], + ); + + return { ...state, start, abort, setData }; +}; + +export type { MutationState, MutationConfig }; +export { useMutation }; diff --git a/src/core/use-query.ts b/src/core/use-query.ts new file mode 100644 index 000000000..6760b657b --- /dev/null +++ b/src/core/use-query.ts @@ -0,0 +1,105 @@ +import { parseError } from "api-4markdown"; +import { ParsedError } from "api-4markdown-contracts"; +import React from "react"; + +type RawError = unknown; +type Idle = { is: "idle" }; +type Busy = { is: "busy" }; +type Ok = { is: "ok"; data: TData }; +type Fail = { is: "fail"; error: ParsedError; rawError: RawError }; +type QueryState = Idle | Busy | Ok | Fail; +type Handler = (signal: AbortSignal) => Promise; + +type QueryConfig = { + initialize?: boolean; + handler?: Handler; + onBusy?: () => void; + onOk?: (data: TData) => void; + onFail?: (error: ParsedError, rawError: RawError) => void; +}; + +const initialState: QueryState = { is: "idle" }; + +const useQuery = (config: QueryConfig = {}) => { + const { onBusy, onOk, onFail, handler, initialize } = config; + const [state, setState] = React.useState>(initialState); + + const configRef = React.useRef>(config); + const abortRef = React.useRef(); + + const start = React.useCallback( + async (handler?: Handler): Promise => { + abortRef.current?.abort(); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const finalHandler = handler || configRef.current.handler; + + if (!finalHandler) { + throw new Error("Handler is required"); + } + + setState({ is: "busy" }); + configRef.current.onBusy?.(); + const data = await finalHandler(controller.signal); + + if (controller.signal.aborted) return; + + setState({ is: "ok", data }); + configRef.current.onOk?.(data); + } catch (error) { + if (controller.signal.aborted) return; + + const parsedError = parseError(error); + setState({ is: "fail", error: parsedError, rawError: error }); + configRef.current.onFail?.(parsedError, error); + } + }, + [setState], + ); + + React.useEffect(() => { + configRef.current = config; + }, [onBusy, onOk, onFail, handler, initialize]); + + React.useEffect(() => { + const initialize = configRef.current.initialize ?? true; + + if (initialize) { + start(); + } + + return () => { + abortRef.current?.abort(); + }; + }, [start]); + + const abort = React.useCallback( + (reset = false) => { + abortRef.current?.abort(); + + if (reset) { + setState(initialState); + } + }, + [setState], + ); + + const setData = React.useCallback( + (data: Partial) => { + setState((prev) => { + if (prev.is === "ok") { + return { is: "ok", data: { ...prev.data, ...data } }; + } + return prev; + }); + }, + [setState], + ); + + return { ...state, start, abort, setData }; +}; + +export { useQuery }; diff --git a/src/core/use-typeahead-query.ts b/src/core/use-typeahead-query.ts new file mode 100644 index 000000000..72ab7fff9 --- /dev/null +++ b/src/core/use-typeahead-query.ts @@ -0,0 +1,110 @@ +import { EMPTY, Subject, from } from "rxjs"; +import { + debounceTime, + switchMap, + catchError, + tap, + distinctUntilChanged, + filter as rxJsFilter, +} from "rxjs/operators"; +import React from "react"; +import { parseError } from "api-4markdown"; +import { ParsedError } from "api-4markdown-contracts"; + +type RawError = unknown; +type Idle = { is: "idle" }; +type Busy = { is: "busy" }; +type Ok = { is: "ok"; data: TData }; +type Fail = { is: "fail"; error: ParsedError; rawError: RawError }; +type TypeaheadState = Idle | Busy | Ok | Fail; + +type Handler = (query: string, signal: AbortSignal) => Promise; + +type TypeaheadConfig = { + delay?: number; + filter?: (query: string) => boolean; + handler?: Handler; + onBusy?: () => void; + onOk?: (data: TData) => void; + onFail?: (error: ParsedError, rawError: unknown) => void; +}; + +const useTypeaheadQuery = (config: TypeaheadConfig = {}) => { + const { delay, filter, handler, onBusy, onOk, onFail } = config; + const [state, setState] = React.useState>({ + is: "idle", + }); + const [subject] = React.useState( + () => new Subject<{ query: string; handler?: Handler }>(), + ); + const [query, setQuery] = React.useState(""); + const abortRef = React.useRef(); + const configRef = React.useRef(config); + + React.useEffect(() => { + configRef.current = config; + }, [delay, filter, handler, onBusy, onOk, onFail]); + + React.useEffect(() => { + const subscription = subject + .pipe( + rxJsFilter( + ({ query }) => configRef.current.filter?.(query) ?? query.length >= 2, + ), + debounceTime(configRef.current.delay || 1000), + distinctUntilChanged((prev, curr) => prev.query === curr.query), + switchMap(({ query, handler }) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setState({ is: "busy" }); + configRef.current.onBusy?.(); + + const finalHandler = handler || configRef.current.handler; + + return ( + finalHandler + ? from(finalHandler(query, controller.signal)) + : from(Promise.reject(new Error("Handler is required"))) + ).pipe( + tap((data) => { + setState({ is: "ok", data }); + configRef.current.onOk?.(data); + }), + catchError((rawError) => { + const error = parseError(rawError); + setState({ is: "fail", error, rawError }); + configRef.current.onFail?.(error, rawError); + return EMPTY; + }), + ); + }), + ) + .subscribe(); + + return () => { + subscription.unsubscribe(); + abortRef.current?.abort(); + }; + }, [subject]); + + const start = React.useCallback( + (query: string, handler?: Handler) => { + setQuery(query); + subject.next({ query, handler }); + }, + [subject], + ); + + const abort = React.useCallback((reset = false) => { + abortRef.current?.abort(); + if (reset) { + setState({ is: "idle" }); + setQuery(""); + } + }, []); + + return { ...state, query, start, abort }; +}; + +export { useTypeaheadQuery }; diff --git a/src/design-system/empty.tsx b/src/design-system/empty.tsx new file mode 100644 index 000000000..061fe4ae0 --- /dev/null +++ b/src/design-system/empty.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { c } from "./c"; +import { Button } from "./button"; + +type EmptyProps = React.ComponentPropsWithoutRef<"div">; +type EmptyIconProps = React.ComponentPropsWithoutRef<"div">; +type EmptyTitleProps = React.ComponentPropsWithoutRef<"h3">; +type EmptyDescriptionProps = React.ComponentPropsWithoutRef<"p">; +type EmptyActionProps = React.ComponentPropsWithoutRef; + +const Empty = ({ className, children, ...props }: EmptyProps) => { + return ( +
    +
    + {children} +
    +
    + ); +}; + +const EmptyIcon = ({ className, children, ...props }: EmptyIconProps) => { + return ( + + ); +}; + +const EmptyTitle = ({ className, children, ...props }: EmptyTitleProps) => { + return ( +

    + {children} +

    + ); +}; + +const EmptyDescription = ({ + className, + children, + ...props +}: EmptyDescriptionProps) => { + return ( +

    + {children} +

    + ); +}; + +const EmptyAction = Button; + +Empty.Icon = EmptyIcon; +Empty.Title = EmptyTitle; +Empty.Description = EmptyDescription; +Empty.Action = EmptyAction; + +export type { + EmptyProps, + EmptyIconProps, + EmptyTitleProps, + EmptyDescriptionProps, + EmptyActionProps, +}; +export { Empty }; diff --git a/src/design-system/error.tsx b/src/design-system/error.tsx new file mode 100644 index 000000000..3a55f09b4 --- /dev/null +++ b/src/design-system/error.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { c } from "./c"; +import { Button } from "./button"; + +type ErrorProps = React.ComponentPropsWithoutRef<"div">; +type ErrorIconProps = React.ComponentPropsWithoutRef<"div">; +type ErrorTitleProps = React.ComponentPropsWithoutRef<"h3">; +type ErrorDescriptionProps = React.ComponentPropsWithoutRef<"p">; +type ErrorActionProps = React.ComponentPropsWithoutRef; + +const Error = ({ className, children, ...props }: ErrorProps) => { + return ( +
    +
    + {children} +
    +
    + ); +}; + +const ErrorIcon = ({ className, children, ...props }: ErrorIconProps) => { + return ( + + ); +}; + +const ErrorTitle = ({ className, children, ...props }: ErrorTitleProps) => { + return ( +

    + {children} +

    + ); +}; + +const ErrorDescription = ({ + className, + children, + ...props +}: ErrorDescriptionProps) => { + return ( +

    + {children} +

    + ); +}; + +const ErrorAction = Button; + +Error.Icon = ErrorIcon; +Error.Title = ErrorTitle; +Error.Description = ErrorDescription; +Error.Action = ErrorAction; + +export type { + ErrorProps, + ErrorIconProps, + ErrorTitleProps, + ErrorDescriptionProps, + ErrorActionProps, +}; +export { Error }; diff --git a/src/design-system/field.tsx b/src/design-system/field.tsx index 3e485640f..ee58e2db1 100644 --- a/src/design-system/field.tsx +++ b/src/design-system/field.tsx @@ -10,11 +10,34 @@ interface FieldProps { const Field = ({ children, className, label, hint }: FieldProps) => { return ( -
    +
    {label && } {children} {hint} -
    + + ); +}; + +type HintProps = React.ComponentPropsWithoutRef<"i">; + +const Hint = ({ children, className, ...props }: HintProps) => { + return ( + + {children} + + ); +}; + +type ErrorProps = React.ComponentPropsWithoutRef<"i">; + +const Error = ({ children, className, ...props }: ErrorProps) => { + return ( + + {children} + ); }; @@ -39,5 +62,7 @@ const Label = ({ }; Field.Label = Label; +Field.Hint = Hint; +Field.Error = Error; export { Field }; diff --git a/src/design-system/loader.tsx b/src/design-system/loader.tsx index 513e7999e..11c6d7537 100644 --- a/src/design-system/loader.tsx +++ b/src/design-system/loader.tsx @@ -1,19 +1,29 @@ import React, { type DetailedHTMLProps, type HTMLAttributes } from "react"; -import c from "classnames"; +import { c } from "./c"; type LoaderSize = "sm" | "md" | "lg" | "xl"; interface LoaderProps extends DetailedHTMLProps, HTMLDivElement> { size?: LoaderSize; + variant?: "primary" | "secondary"; } -const Loader = ({ className, size = `md`, ...props }: LoaderProps) => { +const Loader = ({ + className, + variant = "primary", + size = `md`, + ...props +}: LoaderProps) => { return (
    void; + inputRef: React.MutableRefObject; + listRef: React.MutableRefObject; + }) => props, +); + +// Main Search compound component +const Search = ({ + className, + children, + ...props +}: React.ComponentPropsWithoutRef<"div">) => { + const [isOpen, setIsOpen] = useState(false); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Handle outside clicks to close the search list + useOnOutsideClick(listRef, { + onOutsideClick: () => setIsOpen(false), + enabled: isOpen, + }); + + return ( + +
    + {children} +
    +
    + ); +}; + +// Search Input component +const SearchInput = forwardRef< + HTMLInputElement, + React.ComponentPropsWithoutRef<"input"> & { + onClear?: () => void; + showClear?: boolean; + busy?: boolean; + } +>(({ className, onClear, showClear, onFocus, busy, ...props }, ref) => { + const { setIsOpen, inputRef } = useSearchContext(); + + const handleFocus = (e: React.FocusEvent) => { + setIsOpen(true); + onFocus?.(e); + }; + + return ( +
    +
    + {busy ? ( + + ) : ( + + )} +
    + { + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + inputRef.current = node; + }} + type="text" + className={c( + "block w-full pl-10 pr-10 py-2 placeholder:text-gray-600 dark:placeholder:text-gray-300 text-sm rounded-md bg-gray-300 dark:bg-slate-800 border-[2.5px] border-transparent focus:border-black focus:dark:border-white outline-none", + className, + )} + onFocus={handleFocus} + {...props} + /> + {showClear && ( + + )} +
    + ); +}); + +SearchInput.displayName = "SearchInput"; + +// Search List component +const SearchList = forwardRef< + HTMLUListElement, + React.ComponentPropsWithoutRef<"ul"> +>(({ className, children, ...props }, ref) => { + const { isOpen, listRef } = useSearchContext(); + + if (!isOpen) return null; + + return ( +
      { + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + listRef.current = node; + }} + className={c( + "absolute w-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg overflow-y-auto max-h-80", + className, + )} + {...props} + > + {children} +
    + ); +}); + +SearchList.displayName = "SearchList"; + +// Search List Item component +const SearchListItem = ({ + className, + children, + onClick, + ...props +}: React.ComponentPropsWithoutRef<"li">) => { + const { setIsOpen } = useSearchContext(); + + const handleClick = (e: React.MouseEvent) => { + setIsOpen(false); + onClick?.(e); + }; + + return ( +
  • + {children} +
  • + ); +}; + +Search.Input = SearchInput; +Search.List = SearchList; +Search.ListItem = SearchListItem; + +export { Search }; diff --git a/src/design-system/skeleton.tsx b/src/design-system/skeleton.tsx new file mode 100644 index 000000000..dfba71144 --- /dev/null +++ b/src/design-system/skeleton.tsx @@ -0,0 +1,16 @@ +import React, { ComponentProps } from "react"; +import { c } from "./c"; + +const Skeleton = ({ className, ...props }: ComponentProps<"div">) => { + return ( +
    + ); +}; + +export { Skeleton }; diff --git a/src/design-system/tabs-2.tsx b/src/design-system/tabs-2.tsx new file mode 100644 index 000000000..fa4e9af8b --- /dev/null +++ b/src/design-system/tabs-2.tsx @@ -0,0 +1,65 @@ +import React, { ButtonHTMLAttributes } from "react"; +import { c } from "./c"; + +interface Tabs2Props { + children: React.ReactNode; + className?: string; +} + +const Tabs2 = ({ children, className }: Tabs2Props) => { + return ( +
    *:first-child]:rounded-s-md [&>*:last-child]:rounded-r-md", + className, + )} + > + {children} +
    + ); +}; + +type Tabs2ItemProps = { + active?: boolean; + className?: string; + children: React.ReactNode; +} & ButtonHTMLAttributes; + +const Tabs2Item = ({ active, className, ...props }: Tabs2ItemProps) => { + return ( +