diff --git a/apps/playground/src/app/(platform)/(auth)/onboarding/_components/card.tsx b/apps/playground/src/app/(platform)/(auth)/onboarding/_components/card.tsx new file mode 100644 index 00000000..1b8b229e --- /dev/null +++ b/apps/playground/src/app/(platform)/(auth)/onboarding/_components/card.tsx @@ -0,0 +1,61 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +"use client"; + +import Image from "next/image"; + +import { cn } from "@swy/ui/lib"; + +export interface CardProps { + id: number; + checked: boolean; + image: string; + title: string; + description: string; + onClick?: () => void; +} + +export const Card = ({ + id, + checked, + image, + title, + description, + onClick, +}: CardProps) => { + return ( +
+ + +
+ +
+
+
+ {title} +
+

{description}

+
+
+ ); +}; diff --git a/apps/playground/src/app/(platform)/(auth)/onboarding/page.tsx b/apps/playground/src/app/(platform)/(auth)/onboarding/page.tsx new file mode 100644 index 00000000..acfaea11 --- /dev/null +++ b/apps/playground/src/app/(platform)/(auth)/onboarding/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { v4 } from "uuid"; + +import { useBoundStore } from "@swy/notion"; +import { Button } from "@swy/ui/shadcn"; +import { Plan, Role } from "@swy/validators"; + +import { WorkspaceModel } from "~/db"; +import { useAppActions, useAppState, useMockDB } from "~/hooks"; +import { Card, type CardProps } from "./_components/card"; + +const data: Omit[] = [ + { + id: 0, + image: + "https://www.notion.so/images/onboarding/team-features-illustration.png", + title: "For my team", + description: "Collaborate on your docs, projects, and wikis.", + }, + { + id: 1, + image: "https://www.notion.so/images/onboarding/use-case-note.png", + title: "For personal use", + description: "Write better. Think more clearly. Stay organized.", + }, + { + id: 2, + image: "https://www.notion.so/images/onboarding/use-case-wiki.png", + title: "For school", + description: "Keep your notes, research, and tasks all in one place.", + }, +]; + +export default function Page() { + const [checked, setChecked] = useState(-1); + const router = useRouter(); + const { updateDB } = useMockDB(); + const [isSignedIn, accountId] = useAppState((state) => [ + state.isSignedIn, + state.userId, + ]); + const { selectWorkspace } = useAppActions(); + const addWorkspace = useBoundStore((state) => state.addWorkspace); + + const createWorkspace = async () => { + if (!accountId) { + router.push("/sign-in"); + return; + } + const wid = v4(); + const w: WorkspaceModel = { + id: wid, + name: "New Workspace", + createdBy: accountId, + domain: `workspace-${accountId.slice(0, 5)}`, + inviteToken: wid, + plan: Plan.FREE, + icon: { type: "text", text: "N" }, + lastEditedAt: Date.now(), + }; + await updateDB("workspaces", { [wid]: w }); + const mem = { + id: v4(), + accountId, + workspaceId: w.id, + joinedAt: Date.now(), + role: Role.OWNER, + }; + await updateDB("memberships", [mem]); + addWorkspace({ + id: wid, + name: w.name, + icon: w.icon ?? { type: "text", text: w.name }, + members: 1, + plan: w.plan, + role: mem.role, + }); + console.log(`create success, redirecting to ${wid}`); + selectWorkspace(w.id); + }; + + useEffect(() => { + if (!isSignedIn) router.push("/sign-in"); + }, [isSignedIn, router]); + + return ( + <> +
+
+
+
+ How are you planning to use Notion? +
+
+ We’ll streamline your setup experience accordingly. +
+
+
+
+
+
+ {data.map((props) => ( + setChecked(props.id)} + /> + ))} +
+ +
+
+
+ + + ); +} diff --git a/apps/playground/src/app/(platform)/(auth)/sign-in/_components/sign-in-button.tsx b/apps/playground/src/app/(platform)/(auth)/sign-in/_components/sign-in-button.tsx index e0c02b66..b087c8ce 100644 --- a/apps/playground/src/app/(platform)/(auth)/sign-in/_components/sign-in-button.tsx +++ b/apps/playground/src/app/(platform)/(auth)/sign-in/_components/sign-in-button.tsx @@ -6,14 +6,20 @@ import { Button } from "@swy/ui/shadcn"; interface SignInButtonProps { name: string; avatarUrl: string; + onClick: () => void; } export const SignInButton: React.FC = ({ name, avatarUrl, + onClick, }) => { return ( - + + + ); +}; + +export default HomePage; diff --git a/apps/playground/src/app/(platform)/(core)/layout.tsx b/apps/playground/src/app/(platform)/(core)/layout.tsx new file mode 100644 index 00000000..3b597752 --- /dev/null +++ b/apps/playground/src/app/(platform)/(core)/layout.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React, { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { + Navbar, + PageProvider, + SettingsStore, + Sidebar2, + useBoundStore, + WorkspaceSwitcher2, +} from "@swy/notion"; +import { useSidebarLayout } from "@swy/ui/hooks"; +import { cn } from "@swy/ui/lib"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@swy/ui/shadcn"; + +import { fallbackUser } from "~/constants"; +import { useAppActions, useAppState } from "~/hooks"; + +export default function CoreLayout({ children }: React.PropsWithChildren) { + const router = useRouter(); + const { minSize, ref, collapse, expand, isResizing, isMobile, isCollapsed } = + useSidebarLayout("group", "sidebar", 240); + + const { isSignedIn } = useAppState(); + const { goToOnboarding, selectWorkspace, logout } = useAppActions(); + const user = useBoundStore((state) => state.user); + const workspaces = useBoundStore((state) => state.workspaces); + const activeWorkspace = useBoundStore((state) => state.activeWorkspace); + + useEffect(() => { + if (!isSignedIn || !activeWorkspace) router.push("/sign-in"); + }, [activeWorkspace, isSignedIn, router]); + + return ( + activeWorkspace && ( + + + + } + /> + + + + + +
{children}
+
+
+
+ ) + ); +} diff --git a/apps/playground/src/app/(platform)/layout.tsx b/apps/playground/src/app/(platform)/layout.tsx index 12c23dc3..ac44c52f 100644 --- a/apps/playground/src/app/(platform)/layout.tsx +++ b/apps/playground/src/app/(platform)/layout.tsx @@ -1,19 +1,27 @@ "use client"; import React, { useEffect } from "react"; +import { Toaster } from "sonner"; + +import { ModalProvider } from "@swy/ui/shared"; -import { mockDB } from "~/db"; import { useMockDB } from "~/hooks"; -export default function Layout({ children }: React.PropsWithChildren) { - const { update } = useMockDB(); +export default function PlatformLayout({ children }: React.PropsWithChildren) { + const { setupDB } = useMockDB(); useEffect(() => { - update(mockDB); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + setupDB(); + }, [setupDB]); return ( -
{children}
+ <> + + +
+ {children} +
+
+ ); } diff --git a/apps/playground/src/constants.ts b/apps/playground/src/constants.ts new file mode 100644 index 00000000..df72d30d --- /dev/null +++ b/apps/playground/src/constants.ts @@ -0,0 +1,19 @@ +import type { IconInfo } from "@swy/ui/shared"; +import { type User } from "@swy/validators"; + +export const fallbackIcon: IconInfo = { type: "text", text: "W" }; +export const fallbackUser: User = { + id: "", + name: "", + email: "", + avatarUrl: "", +}; + +// export const fallbackWorkspace: Workspace = { +// id: "", +// role: Role.GUEST, +// name: "", +// icon: fallbackIcon, +// members: 0, +// plan: Plan.FREE, +// }; diff --git a/apps/playground/src/db/actions/index.ts b/apps/playground/src/db/actions/index.ts new file mode 100644 index 00000000..5178c366 --- /dev/null +++ b/apps/playground/src/db/actions/index.ts @@ -0,0 +1 @@ +export * from "./workspace"; diff --git a/apps/playground/src/db/actions/workspace.ts b/apps/playground/src/db/actions/workspace.ts new file mode 100644 index 00000000..f6278243 --- /dev/null +++ b/apps/playground/src/db/actions/workspace.ts @@ -0,0 +1,35 @@ +import type { Workspace } from "@swy/notion"; + +import { delay } from "~/lib/utils"; +import type { MockDB } from "../mock-db"; + +export const findAccount = async ( + db: Pick, + accountId: string, +) => { + await delay(500); + return await Promise.resolve(db.accounts[accountId] ?? null); +}; + +export const findAccountMemberships = async ( + db: Pick, + accountId: string, +): Promise => { + await delay(500); + const mems = db.memberships.filter((mem) => mem.accountId === accountId); + const ws = mems.map((mem) => { + const w = db.workspaces[mem.workspaceId]!; + const membersCount = db.memberships.filter( + (mem) => mem.workspaceId === w.id, + ).length; + return { + id: w.id, + role: mem.role, + name: w.name, + icon: w.icon ?? { type: "text", text: w.name }, + members: membersCount, + plan: w.plan, + }; + }); + return await Promise.resolve(ws); +}; diff --git a/apps/playground/src/db/accounts.ts b/apps/playground/src/db/data/accounts.ts similarity index 96% rename from apps/playground/src/db/accounts.ts rename to apps/playground/src/db/data/accounts.ts index 93c279fe..07ae84c7 100644 --- a/apps/playground/src/db/accounts.ts +++ b/apps/playground/src/db/data/accounts.ts @@ -1,4 +1,4 @@ -import { AccountModel } from "./types"; +import type { AccountModel } from "../types"; export enum _USER { U1 = "a97f4e50-0b72-44f4-a95a-aba319534af5", diff --git a/apps/playground/src/db/data/index.ts b/apps/playground/src/db/data/index.ts new file mode 100644 index 00000000..cb56ceb3 --- /dev/null +++ b/apps/playground/src/db/data/index.ts @@ -0,0 +1,3 @@ +export * from "./accounts"; +export * from "./memberships"; +export * from "./workspaces"; diff --git a/apps/playground/src/db/memberships.ts b/apps/playground/src/db/data/memberships.ts similarity index 96% rename from apps/playground/src/db/memberships.ts rename to apps/playground/src/db/data/memberships.ts index c7a9b6a7..7a499a50 100644 --- a/apps/playground/src/db/memberships.ts +++ b/apps/playground/src/db/data/memberships.ts @@ -1,7 +1,7 @@ import { Role } from "@swy/validators"; +import type { MembershipModel } from "../types"; import { _USER } from "./accounts"; -import type { MembershipModel } from "./types"; import { _WORKSPACE } from "./workspaces"; export const memberships: MembershipModel[] = [ diff --git a/apps/playground/src/db/workspaces.ts b/apps/playground/src/db/data/workspaces.ts similarity index 97% rename from apps/playground/src/db/workspaces.ts rename to apps/playground/src/db/data/workspaces.ts index 1e9d5115..b9aea613 100644 --- a/apps/playground/src/db/workspaces.ts +++ b/apps/playground/src/db/data/workspaces.ts @@ -1,7 +1,7 @@ import { Plan } from "@swy/validators"; +import type { WorkspaceModel } from "../types"; import { _USER } from "./accounts"; -import type { WorkspaceModel } from "./types"; export enum _WORKSPACE { W1 = "f12d4c5b-2d3b-4d2b-aef3-8f7319c5d481", diff --git a/apps/playground/src/db/index.ts b/apps/playground/src/db/index.ts index 3bbf07b2..7f38a064 100644 --- a/apps/playground/src/db/index.ts +++ b/apps/playground/src/db/index.ts @@ -1,16 +1,3 @@ -import { accounts } from "./accounts"; -import { memberships } from "./memberships"; -import type { AccountModel, MembershipModel, WorkspaceModel } from "./types"; -import { workspaces } from "./workspaces"; - -export interface MockDB { - accounts: Record; - workspaces: Record; - memberships: MembershipModel[]; -} - -export const mockDB: MockDB = { - accounts, - workspaces, - memberships, -}; +export * as actions from "./actions"; +export * from "./mock-db"; +export type * from "./types"; diff --git a/apps/playground/src/db/mock-db.ts b/apps/playground/src/db/mock-db.ts new file mode 100644 index 00000000..b0c80c0f --- /dev/null +++ b/apps/playground/src/db/mock-db.ts @@ -0,0 +1,14 @@ +import { accounts, memberships, workspaces } from "./data"; +import type { AccountModel, MembershipModel, WorkspaceModel } from "./types"; + +export interface MockDB { + accounts: Record; + workspaces: Record; + memberships: MembershipModel[]; +} + +export const mockDB: MockDB = { + accounts, + workspaces, + memberships, +}; diff --git a/apps/playground/src/hooks/index.ts b/apps/playground/src/hooks/index.ts index 2183ad53..7241986e 100644 --- a/apps/playground/src/hooks/index.ts +++ b/apps/playground/src/hooks/index.ts @@ -1 +1,3 @@ +export * from "./useAppActions"; +export * from "./useAppControl"; export * from "./useMockDB"; diff --git a/apps/playground/src/hooks/useAppActions.ts b/apps/playground/src/hooks/useAppActions.ts new file mode 100644 index 00000000..432aeb8f --- /dev/null +++ b/apps/playground/src/hooks/useAppActions.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import { useBoundStore } from "@swy/notion"; + +import { useAppState } from "./useAppControl"; + +export const useAppActions = () => { + const router = useRouter(); + const { signOut } = useAppState(); + /** Store */ + const setActiveWorkspace = useBoundStore((state) => state.setActiveWorkspace); + const resetStore = useBoundStore((state) => () => { + state.resetWorkspaces(); + state.resetUser(); + }); + /** Actions */ + const goToOnboarding = useCallback( + () => router.push("/onboarding"), + [router], + ); + const selectWorkspace = useCallback( + (workspaceId: string) => { + setActiveWorkspace(workspaceId); + router.push(`/home/${workspaceId}`); + }, + [router, setActiveWorkspace], + ); + + const logout = useCallback(() => { + resetStore(); + signOut(); + }, [resetStore, signOut]); + + return { + goToOnboarding, + selectWorkspace, + logout, + }; +}; diff --git a/apps/playground/src/hooks/useAppControl.ts b/apps/playground/src/hooks/useAppControl.ts new file mode 100644 index 00000000..e4921239 --- /dev/null +++ b/apps/playground/src/hooks/useAppControl.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +type AppState = { + signIn: (userId: string) => void; + signOut: () => void; +} & ( + | { isSignedIn: false; userId: null } + | { isSignedIn: true; userId: string } +); + +export const useAppState = create()( + persist( + (set) => ({ + isSignedIn: false, + userId: null, + signIn: (userId) => set({ isSignedIn: true, userId }), + signOut: () => set({ isSignedIn: false, userId: null }), + }), + { name: "app" }, + ), +); diff --git a/apps/playground/src/hooks/useMockDB.ts b/apps/playground/src/hooks/useMockDB.ts index 2bf080db..e57515f3 100644 --- a/apps/playground/src/hooks/useMockDB.ts +++ b/apps/playground/src/hooks/useMockDB.ts @@ -1,8 +1,10 @@ "use client"; +import { useCallback } from "react"; import { useLocalStorage } from "usehooks-ts"; -import type { MockDB } from "~/db"; +import { actions, mockDB, type MockDB } from "~/db"; +import { delay } from "~/lib/utils"; const initial: MockDB = { accounts: {}, @@ -10,12 +12,66 @@ const initial: MockDB = { memberships: [], }; +function determineType(input: unknown): "object" | "array" | "else" { + return Array.isArray(input) + ? "array" + : typeof input === "object" && input !== null + ? "object" + : "else"; +} + export const useMockDB = () => { - const [value, update] = useLocalStorage("mock:db", initial); + const [db, update] = useLocalStorage("mock:db", initial); + const setupDB = useCallback(() => update(mockDB), [update]); + const updateDB = useCallback( + async (collection: K, data: MockDB[K]) => { + await delay(500); + let ok = false; + try { + update((prev) => { + const storeType = determineType(prev[collection]); + if (storeType === "else") return prev; + return { + ...prev, + [collection]: + storeType === "object" + ? { ...prev[collection], ...data } + : [...(prev[collection] as unknown[]), ...(data as unknown[])], + }; + }); + ok = true; + } catch (error) { + console.error("update db error", error); + } + return Promise.resolve(ok); + }, + [update], + ); + const resetDB = useCallback(() => update(initial), [update]); + /** fetchers */ + const findAccount = useCallback( + (accountId: string) => + actions.findAccount({ accounts: db.accounts }, accountId), + [db.accounts], + ); + const findAccountMemberships = useCallback( + (accountId: string) => + actions.findAccountMemberships( + { + workspaces: db.workspaces, + memberships: db.memberships, + }, + accountId, + ), + [db.memberships, db.workspaces], + ); return { - ...value, - update, - reset: () => update(initial), + setupDB, + updateDB, + resetDB, + /** fetchers */ + findAccount, + findAccountMemberships, }; }; diff --git a/apps/playground/src/lib/utils.ts b/apps/playground/src/lib/utils.ts new file mode 100644 index 00000000..86ac6261 --- /dev/null +++ b/apps/playground/src/lib/utils.ts @@ -0,0 +1,2 @@ +export const delay = async (timeout: number) => + await new Promise((resolve) => setTimeout(resolve, timeout)); diff --git a/packages/notion/src/slices/use-bound-store.ts b/packages/notion/src/slices/use-bound-store.ts index de0b9509..a9f5fe9c 100644 --- a/packages/notion/src/slices/use-bound-store.ts +++ b/packages/notion/src/slices/use-bound-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; import { createUserSlice, type UserSlice } from "./account"; @@ -7,11 +8,16 @@ import { createWorkspaceSlice, type WorkspaceSlice } from "./workspace"; type Store = UserSlice & WorkspaceSlice & PageSlice; -const useStore = create((...a) => ({ - ...createUserSlice(...a), - ...createWorkspaceSlice(...a), - ...createPageSlice(...a), -})); +const useStore = create( + persist( + (...a) => ({ + ...createUserSlice(...a), + ...createWorkspaceSlice(...a), + ...createPageSlice(...a), + }), + { name: "platform" }, + ), +); export const useBoundStore = (selector: (state: Store) => T) => useStore(useShallow(selector));