diff --git a/app/canvas/canvas.tsx b/app/canvas/canvas.tsx index 48590d9a3a..54698f3a67 100644 --- a/app/canvas/canvas.tsx +++ b/app/canvas/canvas.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { DndProvider } from "react-dnd"; import { TouchBackend } from "react-dnd-touch-backend"; import store from "immerhin"; +import * as db from "~/shared/db"; import { type OnChangeChildren, type Data, @@ -41,7 +42,6 @@ import { useHandleBreakpoints, useInitializeBreakpoints, } from "./shared/breakpoints"; -import type { Project } from "~/shared/db/project.server"; import { rootInstanceContainer, useBreakpoints, @@ -102,7 +102,7 @@ const PreviewMode = () => { type DesignModeProps = { treeId: Tree["id"]; - project: Project; + project: db.project.Project; }; const dndOptions = { enableMouseEvents: true }; @@ -141,7 +141,7 @@ const DesignMode = ({ treeId, project }: DesignModeProps) => { }; type CanvasProps = { - data: Data & { project: Project }; + data: Data & { project: db.project.Project }; }; export const Canvas = ({ data }: CanvasProps): JSX.Element | null => { diff --git a/app/canvas/shared/sync/use-sync.ts b/app/canvas/shared/sync/use-sync.ts index 9250d9e559..d2a1e5080d 100644 --- a/app/canvas/shared/sync/use-sync.ts +++ b/app/canvas/shared/sync/use-sync.ts @@ -1,9 +1,9 @@ import useInterval from "react-use/lib/useInterval"; import { sync } from "immerhin"; import { enqueue } from "./queue"; -import type { Project } from "~/shared/db/project.server"; +import * as db from "~/shared/db"; -export const useSync = ({ project }: { project: Project }) => { +export const useSync = ({ project }: { project: db.project.Project }) => { useInterval(() => { const entries = sync(); if (entries.length === 0) return; diff --git a/app/routes/auth/github.tsx b/app/routes/auth/github.tsx index 4754126d46..fdec6900be 100644 --- a/app/routes/auth/github.tsx +++ b/app/routes/auth/github.tsx @@ -2,7 +2,7 @@ import { ActionFunction, redirect } from "@remix-run/node"; import config from "~/config"; import { authenticator } from "~/services/auth.server"; import { sentryException } from "~/shared/sentry"; -import { ensureUserCookie, AUTH_PROVIDERS } from "~/shared/session"; +import { AUTH_PROVIDERS } from "~/shared/session"; export default function GH() { return null; @@ -10,11 +10,7 @@ export default function GH() { export const action: ActionFunction = async ({ request }) => { try { - const { userId } = await ensureUserCookie(request); return await authenticator.authenticate("github", request, { - context: { - userId, - }, successRedirect: config.dashboardPath, throwOnError: true, }); diff --git a/app/routes/auth/github/callback.tsx b/app/routes/auth/github/callback.tsx index 92660d1b78..0314b96268 100644 --- a/app/routes/auth/github/callback.tsx +++ b/app/routes/auth/github/callback.tsx @@ -1,10 +1,9 @@ import { LoaderFunction, redirect } from "@remix-run/node"; import config from "~/config"; import { authenticator } from "~/services/auth.server"; -import { ensureUserCookie, AUTH_PROVIDERS } from "~/shared/session"; +import { AUTH_PROVIDERS } from "~/shared/session"; export const loader: LoaderFunction = async ({ request }) => { - const { userId } = await ensureUserCookie(request); const url = new URL(request.url); const error = url.searchParams.get("error"); const error_description = url.searchParams.get("error_description"); @@ -16,9 +15,6 @@ export const loader: LoaderFunction = async ({ request }) => { ); } return authenticator.authenticate("github", request, { - context: { - userId, - }, successRedirect: config.dashboardPath, failureRedirect: config.loginPath, }); diff --git a/app/routes/auth/google.tsx b/app/routes/auth/google.tsx index f04c3f8ff8..380bb202c3 100644 --- a/app/routes/auth/google.tsx +++ b/app/routes/auth/google.tsx @@ -2,7 +2,7 @@ import { ActionFunction, LoaderFunction, redirect } from "@remix-run/node"; import config from "~/config"; import { authenticator } from "~/services/auth.server"; import { sentryException } from "~/shared/sentry"; -import { ensureUserCookie, AUTH_PROVIDERS } from "~/shared/session"; +import { AUTH_PROVIDERS } from "~/shared/session"; export default function Google() { return null; @@ -12,11 +12,7 @@ export const loader: LoaderFunction = () => redirect("/login"); export const action: ActionFunction = async ({ request }) => { try { - const { userId } = await ensureUserCookie(request); return await authenticator.authenticate("google", request, { - context: { - userId, - }, successRedirect: config.dashboardPath, throwOnError: true, }); diff --git a/app/routes/auth/google/callback.tsx b/app/routes/auth/google/callback.tsx index f56616122d..5eaf1b129a 100644 --- a/app/routes/auth/google/callback.tsx +++ b/app/routes/auth/google/callback.tsx @@ -1,15 +1,10 @@ import { LoaderFunction } from "@remix-run/node"; import config from "~/config"; import { authenticator } from "~/services/auth.server"; -import { ensureUserCookie } from "~/shared/session"; export const loader: LoaderFunction = async ({ request }) => { - const { userId } = await ensureUserCookie(request); return authenticator.authenticate("google", request, { successRedirect: config.dashboardPath, failureRedirect: config.loginPath, - context: { - userId, - }, }); }; diff --git a/app/routes/rest/project/clone.$domain.ts b/app/routes/rest/project/clone.$domain.ts index 69d58a52c8..6c4a09021c 100644 --- a/app/routes/rest/project/clone.$domain.ts +++ b/app/routes/rest/project/clone.$domain.ts @@ -3,7 +3,7 @@ import type { User } from "@webstudio-is/sdk"; import * as db from "~/shared/db"; import { ensureUserCookie } from "~/shared/session"; import config from "~/config"; -import type { Project } from "~/shared/db/project.server"; +import { authenticator } from "~/services/auth.server"; const ensureProject = async ({ userId, @@ -11,7 +11,7 @@ const ensureProject = async ({ }: { userId: User["id"]; domain: string; -}): Promise => { +}): Promise => { const projects = await db.project.loadManyByUserId(userId); if (projects.length !== 0) return projects[0]; @@ -28,9 +28,16 @@ const ensureProject = async ({ */ export const loader: LoaderFunction = async ({ request, params }) => { if (params.domain === undefined) return { errors: "Domain required" }; - const { headers, userId } = await ensureUserCookie(request); + const user = await authenticator.isAuthenticated(request); + const { headers, userId: generatedUserId } = await ensureUserCookie(request); try { - const project = await ensureProject({ userId, domain: params.domain }); + const userId = await db.user.ensureUser({ + userId: user ? user.id : generatedUserId, + }); + const project = await ensureProject({ + userId, + domain: params.domain, + }); return redirect(`${config.designerPath}/${project?.id}`, { headers }); } catch (error: unknown) { if (error instanceof Error) { diff --git a/app/services/auth.server.ts b/app/services/auth.server.ts index 82ebc85ebd..06b7456c93 100644 --- a/app/services/auth.server.ts +++ b/app/services/auth.server.ts @@ -4,12 +4,8 @@ import { FormStrategy } from "remix-auth-form"; import { GitHubStrategy } from "remix-auth-github"; import { GoogleStrategy } from "remix-auth-google"; import config from "~/config"; +import * as db from "~/shared/db"; import { sessionStorage } from "~/services/session.server"; -import { - createOrLoginWithDev, - createOrLoginWithGithub, - createOrLoginWithGoogle, -} from "~/shared/db/user.server"; import { sentryException } from "~/shared/sentry"; import { AUTH_PROVIDERS } from "~/shared/session"; @@ -25,8 +21,8 @@ const github = new GitHubStrategy( clientSecret: process.env.GH_CLIENT_SECRET as string, callbackURL: `${url}${config.githubCallbackPath}`, }, - async ({ profile, context }) => { - const user = await createOrLoginWithGithub(profile, context?.userId); + async ({ profile }) => { + const user = await db.user.createOrLoginWithGithub(profile); return user; } @@ -38,8 +34,8 @@ const google = new GoogleStrategy( clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, callbackURL: `${url}${config.googleCallbackPath}`, }, - async ({ profile, context }) => { - const user = await createOrLoginWithGoogle(profile, context?.userId); + async ({ profile }) => { + const user = await db.user.createOrLoginWithGoogle(profile); return user; } @@ -61,7 +57,7 @@ if (process.env.DEV_LOGIN === "true") { if (secret === process.env.AUTH_SECRET) { try { - const user = await createOrLoginWithDev(secret); + const user = await db.user.createOrLoginWithDev(); return user; } catch (error: unknown) { if (error instanceof Error) { diff --git a/app/shared/db/index.ts b/app/shared/db/index.ts index b220b5bc0b..8e5524eaa7 100644 --- a/app/shared/db/index.ts +++ b/app/shared/db/index.ts @@ -2,5 +2,6 @@ export * as tree from "./tree.server"; export * as project from "./project.server"; export * as props from "./props.server"; export * as misc from "./misc.server"; +export * as user from "./user.server"; export * from "./canvas.server"; export * as breakpoints from "./breakpoints.server"; diff --git a/app/shared/db/user.server.ts b/app/shared/db/user.server.ts index 5524ec8e0a..24735e8be5 100644 --- a/app/shared/db/user.server.ts +++ b/app/shared/db/user.server.ts @@ -3,45 +3,40 @@ import { GitHubProfile } from "remix-auth-github"; import { GoogleProfile } from "remix-auth-google"; import { prisma } from "./prisma.server"; -const genericCreateAccount = async ( - userData: { - email: string; - username: string; - image: string; - provider: string; - }, - userId: string -): Promise => { - const existingUserWithId = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - if (existingUserWithId) { - const connectedUser = await prisma.user.update({ - where: { - id: userId, - }, - data: userData, - }); - - if (connectedUser.teamId) { - return connectedUser; - } - - await prisma.team.create({ - data: { - users: { - connect: { - id: connectedUser.id, - }, +export const createDemoUser = async (userId: string) => { + await prisma.team.create({ + data: { + users: { + create: { + id: userId, }, }, - }); + }, + }); +}; - return connectedUser; +export const ensureUser = async ({ userId }: { userId: string }) => { + // Always check if the account userId exists because account could have been deleted + // or we could be in a demo mode with a generated user + const isUserCreated = Boolean( + await prisma.user.findUnique({ + where: { id: userId }, + }) + ); + if (isUserCreated) { + return userId; } + await createDemoUser(userId); + return userId; +}; + +const genericCreateAccount = async (userData: { + email: string; + username: string; + image: string; + provider: string; +}): Promise => { const existingUserWithEmail = await prisma.user.findUnique({ where: { email: userData.email, @@ -68,18 +63,11 @@ const genericCreateAccount = async ( const newTeam = await prisma.team.create({ data: { users: { - create: { - id: userId, - ...userData, - }, + create: userData, }, }, include: { - users: { - where: { - id: userId, - }, - }, + users: true, }, }); @@ -87,8 +75,7 @@ const genericCreateAccount = async ( }; export const createOrLoginWithGithub = async ( - profile: GitHubProfile, - userId: string + profile: GitHubProfile ): Promise => { const userData = { email: profile._json.email, @@ -96,13 +83,12 @@ export const createOrLoginWithGithub = async ( image: profile._json.avatar_url, provider: profile.provider, }; - const newUser = await genericCreateAccount(userData, userId); + const newUser = await genericCreateAccount(userData); return newUser; }; export const createOrLoginWithGoogle = async ( - profile: GoogleProfile, - userId: string + profile: GoogleProfile ): Promise => { const userData = { email: profile._json.email, @@ -110,11 +96,11 @@ export const createOrLoginWithGoogle = async ( image: profile._json.picture, provider: profile.provider, }; - const newUser = await genericCreateAccount(userData, userId); + const newUser = await genericCreateAccount(userData); return newUser; }; -export const createOrLoginWithDev = async (userId: string): Promise => { +export const createOrLoginWithDev = async (): Promise => { const userData = { email: "hello@webstudio.is", username: "admin", @@ -122,6 +108,6 @@ export const createOrLoginWithDev = async (userId: string): Promise => { provider: "dev", }; - const newUser = await genericCreateAccount(userData, userId); + const newUser = await genericCreateAccount(userData); return newUser; }; diff --git a/app/shared/design-system/components/primitives/create-content-controller.stories.tsx b/app/shared/design-system/components/primitives/create-content-controller.stories.tsx new file mode 100644 index 0000000000..4110771803 --- /dev/null +++ b/app/shared/design-system/components/primitives/create-content-controller.stories.tsx @@ -0,0 +1,68 @@ +import { useState, useRef, useEffect, RefObject } from "react"; +import { createContentController } from "./create-content-controller"; + +const useContentController = ({ + ref, + read, + write, + callback, +}: { + ref: RefObject; + read: (value: HTMLInputElement) => string; + write: (target: HTMLInputElement, value: string) => void; + callback: (value: { name: string; value: string }) => void; +}) => { + useEffect(() => { + if (ref.current == null) return; + const { disconnectedCallback } = createContentController(ref.current, { + contents: [ + { + name: "unit", + match: (value: string) => ["px", "em", "rem", "%"].includes(value), + }, + { + name: "number", + match: (value: string) => isNaN(parseFloat(value)) === false, + }, + { name: "unknown", match: (value: string) => Boolean(value) }, + ], + read, + write, + onMouseMove: callback, + onCaretMove: callback, + }); + return () => disconnectedCallback(); + }, [ref, read, write, callback]); +}; + +const ExampleContentController = () => { + const [content, setContent] = useState({ name: "none", value: "" }); + const ref = useRef(null); + useContentController({ + ref, + callback: (payload) => setContent(payload), + read: (target) => target.value, + write: (target, value) => (target.value = value), + }); + return ( +
+
+ +
+
+

Currently on: {content.name}

+

With a value of: {content.value}

+
+
+ ); +}; + +export const DetectInput = Object.assign(ExampleContentController.bind({})); + +export default { + component: ExampleContentController, +}; diff --git a/app/shared/design-system/components/primitives/create-content-controller.ts b/app/shared/design-system/components/primitives/create-content-controller.ts new file mode 100644 index 0000000000..47662fed25 --- /dev/null +++ b/app/shared/design-system/components/primitives/create-content-controller.ts @@ -0,0 +1,142 @@ +/** + * @description + * - detects whether the pointer/keyboard is on any given token as specified by the {contents: []} contract. + * - pointermove/keyup in turn dispatch onMouseMove/onCaretMove with the current state if and when within a configured content token. + * - returns two methods {update, disconnectedCallback} the former of which dispatches a content update(while preserving the caret position if any) + * - read/write methods are called when reading from the passed element or when updating/writing to the same element. + * @example + * const target = document.querySelector('input'); + * const {update, disconnectedCallback} = createContentController(target, { + * contents: [ + * { name: 'unit', match: (value) => value === 'px' }, + * { name: 'number', match: (value) => isNaN(parseFloat(value)) === false }, + * { name: 'unknown', match: (value) => true }, + * ], + * onMouseMove: ({name, value, position}) => { }, + * onCaretMove: ({name, value, position}) => { }, + * read: (target) => target.value, + * write: (target, value) => target.value = value, + * }); + */ +const canvasContext = globalThis.document + .createElement("canvas") + .getContext("2d") as CanvasRenderingContext2D & { letterSpacing: string }; +const measureTextWidth = (value: string): number => + canvasContext.measureText(value).width; +const measureTextPadding = (node: HTMLElement, value: string) => { + const { + fontSize, + fontFamily, + fontWeight, + textAlign, + letterSpacing, + paddingLeft, + paddingRight, + } = getComputedStyle(node); + if (letterSpacing !== "normal") canvasContext.letterSpacing = letterSpacing; + canvasContext.font = `${fontWeight} ${fontSize} ${fontFamily}`; + return ["left", "start"].includes(textAlign) + ? parseFloat(paddingLeft) + : node.clientWidth - + canvasContext.measureText(value).width - + parseFloat(paddingRight); +}; +const measureTextMetrics = (value: string, index: number, offset: number) => { + let length = 0; + const DECIMAL_OR_WHITESPACE_OR_NON_WORD = /([-+]?\d*\.?\d+)|([\s\W])/; + return value + .split(DECIMAL_OR_WHITESPACE_OR_NON_WORD) + .filter((value) => value) + .map((value) => { + const width = measureTextWidth(value); + const head = index - offset; + const tail = index - (offset + width); + const props = { head, tail, value, width, length, offset }; + length += value.length; + offset += width; + return props; + }); +}; +export const createContentController = ( + targetNode: HTMLInputElement, + { + read = (node: HTMLInputElement) => node.value, + write = (node: HTMLInputElement, value: string) => (node.value = value), + contents = [], + onMouseMove = (value: { name: string; value: string; position: string }) => + value, + onCaretMove = (value: { name: string; value: string; position: string }) => + value, + }: { + read: (node: HTMLInputElement) => string; + write: (node: HTMLInputElement, value: string) => void; + contents: { + name: string; + match: (value: string) => boolean; + }[]; + onMouseMove: (value: { + name: string; + value: string; + position: string; + }) => void; + onCaretMove: (value: { + name: string; + value: string; + position: string; + }) => void; + } +): { + update: (value: string) => void; + disconnectedCallback: () => void; +} => { + const eventNames = ["keyup", "pointermove"] as const; + const handleEvent = (event: KeyboardEvent | PointerEvent): void => { + let { offsetX } = event as PointerEvent; + const { type } = event; + const targetValue = read(targetNode); + const targetPadding = measureTextPadding(targetNode, targetValue); + if (type === eventNames[0]) + offsetX = + targetPadding + + measureTextWidth( + targetValue.slice(0, targetNode.selectionStart || undefined) + ); + const targetMetrics = measureTextMetrics( + targetValue, + offsetX, + targetPadding + ); + for (const targetMetricsData of targetMetrics) { + const { head, tail, value } = targetMetricsData; + // zero for exact boundary match, added buffer for precision forgiveness, more so from the head + if (head >= -2 && tail <= 0) { + const current = contents.find(({ match }) => match(value)); + if (current) { + const { name } = current; + const position = `${head},${tail}`; + if (type === eventNames[0]) { + onCaretMove({ name, value, position }); + } else if (type === eventNames[1]) { + onMouseMove({ name, value, position }); + } + } + } + } + }; + + eventNames.forEach((eventName) => + targetNode.addEventListener(eventName, handleEvent, false) + ); + return { + update: (value) => { + const { selectionStart, selectionEnd } = targetNode; + write(targetNode, value); + targetNode.setSelectionRange(selectionStart, selectionEnd); + }, + disconnectedCallback: () => { + eventNames.forEach((eventName) => + targetNode.removeEventListener(eventName, handleEvent, false) + ); + }, + }; +};