mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
Merge pull request #116 from webstudio-is/main
This commit is contained in:
12 files changed
+273
-90
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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<Project> => {
|
||||
}): Promise<db.project.Project> => {
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
@@ -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<User> => {
|
||||
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<User> => {
|
||||
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<User> => {
|
||||
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<User> => {
|
||||
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<User> => {
|
||||
export const createOrLoginWithDev = async (): Promise<User> => {
|
||||
const userData = {
|
||||
email: "hello@webstudio.is",
|
||||
username: "admin",
|
||||
@@ -122,6 +108,6 @@ export const createOrLoginWithDev = async (userId: string): Promise<User> => {
|
||||
provider: "dev",
|
||||
};
|
||||
|
||||
const newUser = await genericCreateAccount(userData, userId);
|
||||
const newUser = await genericCreateAccount(userData);
|
||||
return newUser;
|
||||
};
|
||||
@@ -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<HTMLInputElement>;
|
||||
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<HTMLInputElement | null>(null);
|
||||
useContentController({
|
||||
ref,
|
||||
callback: (payload) => setContent(payload),
|
||||
read: (target) => target.value,
|
||||
write: (target, value) => (target.value = value),
|
||||
});
|
||||
return (
|
||||
<form style={{ fontFamily: "sans-serif" }}>
|
||||
<fieldset>
|
||||
<input
|
||||
defaultValue="100px 20% fit-content calc(0.2px, -99em)"
|
||||
ref={ref}
|
||||
style={{ width: "50%" }}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<h1>Currently on: {content.name}</h1>
|
||||
<h1>With a value of: {content.value}</h1>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const DetectInput = Object.assign(ExampleContentController.bind({}));
|
||||
|
||||
export default {
|
||||
component: ExampleContentController,
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user