mirror of
https://github.com/webstudio-is/webstudio.git
synced 2025-03-14 09:57:02 +00:00
feat: Dashboards search (#4781)
## Description - [ ] Search the projects - [ ] Use arrow keys directly from the search or by focusing the project - [ ] Hit enter to open - [ ] Empty state when nothing found ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
@ -15,7 +15,6 @@ import { SecretLogin } from "./secret-login";
|
||||
const globalStyles = globalCss({
|
||||
body: {
|
||||
margin: 0,
|
||||
background: theme.colors.brandBackgroundDashboard,
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
@ -35,7 +34,14 @@ export const Login = ({
|
||||
}: LoginProps) => {
|
||||
globalStyles();
|
||||
return (
|
||||
<Flex align="center" justify="center" css={{ height: "100vh" }}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
css={{
|
||||
height: "100vh",
|
||||
background: theme.colors.brandBackgroundDashboard,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
@ -56,34 +62,30 @@ export const Login = ({
|
||||
</Text>
|
||||
|
||||
<TooltipProvider>
|
||||
<Flex
|
||||
as={Form}
|
||||
method="post"
|
||||
direction="column"
|
||||
gap="3"
|
||||
css={{ width: "100%" }}
|
||||
>
|
||||
<Button
|
||||
disabled={isGoogleEnabled === false}
|
||||
prefix={<GoogleIcon size={22} />}
|
||||
color="primary"
|
||||
css={{ height: theme.spacing[15] }}
|
||||
formAction={authPath({ provider: "google" })}
|
||||
>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isGithubEnabled === false}
|
||||
prefix={<GithubIcon size={22} fill="currentColor" />}
|
||||
color="ghost"
|
||||
css={{
|
||||
border: `1px solid ${theme.colors.borderDark}`,
|
||||
height: theme.spacing[15],
|
||||
}}
|
||||
formAction={authPath({ provider: "github" })}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
<Flex direction="column" gap="3" css={{ width: "100%" }}>
|
||||
<Form method="post" style={{ display: "contents" }}>
|
||||
<Button
|
||||
disabled={isGoogleEnabled === false}
|
||||
prefix={<GoogleIcon size={22} />}
|
||||
color="primary"
|
||||
css={{ height: theme.spacing[15] }}
|
||||
formAction={authPath({ provider: "google" })}
|
||||
>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isGithubEnabled === false}
|
||||
prefix={<GithubIcon size={22} fill="currentColor" />}
|
||||
color="ghost"
|
||||
css={{
|
||||
border: `1px solid ${theme.colors.borderDark}`,
|
||||
height: theme.spacing[15],
|
||||
}}
|
||||
formAction={authPath({ provider: "github" })}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
</Form>
|
||||
{isSecretLoginEnabled && <SecretLogin />}
|
||||
</Flex>
|
||||
</TooltipProvider>
|
||||
|
@ -5,30 +5,25 @@ import { authPath } from "~/shared/router-utils";
|
||||
export const SecretLogin = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
if (show) {
|
||||
const action = authPath({ provider: "dev" });
|
||||
return (
|
||||
<Flex gap="2">
|
||||
<InputField
|
||||
name="secret"
|
||||
type="password"
|
||||
minLength={2}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Auth secret"
|
||||
css={{ flexGrow: 1 }}
|
||||
formAction={authPath({ provider: "dev" })}
|
||||
onKeyDown={(event) => {
|
||||
const form = event.currentTarget.form;
|
||||
if (event.key === "Enter" && form) {
|
||||
form.action = action;
|
||||
form.submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" formAction={action}>
|
||||
Login
|
||||
</Button>
|
||||
</Flex>
|
||||
<form
|
||||
method="post"
|
||||
action={authPath({ provider: "dev" })}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<Flex gap="2">
|
||||
<InputField
|
||||
name="secret"
|
||||
type="password"
|
||||
minLength={2}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Auth secret"
|
||||
css={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button type="submit">Login</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -219,7 +219,7 @@ export const ComponentsPanel = ({
|
||||
|
||||
const searchFieldProps = useSearchFieldKeys({
|
||||
onChange: resetSelectedComponent,
|
||||
onCancel: resetSelectedComponent,
|
||||
onAbort: resetSelectedComponent,
|
||||
onMove({ direction }) {
|
||||
if (direction === "current") {
|
||||
const component = getSelectedComponent();
|
||||
|
@ -1,12 +1,12 @@
|
||||
import type { StoryFn } from "@storybook/react";
|
||||
import type { JSX } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { Dashboard } from "./dashboard";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||
import { Dashboard, DashboardSetup } from "./dashboard";
|
||||
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
|
||||
import type { DashboardProject } from "@webstudio-is/dashboard";
|
||||
|
||||
export default {
|
||||
title: "Dashboard / Projects",
|
||||
title: "Dashboard",
|
||||
component: Dashboard,
|
||||
};
|
||||
|
||||
@ -20,14 +20,10 @@ const user = {
|
||||
provider: "github",
|
||||
};
|
||||
|
||||
const createRouter = (element: JSX.Element) =>
|
||||
createBrowserRouter([
|
||||
{
|
||||
path: "*",
|
||||
element,
|
||||
loader: () => null,
|
||||
},
|
||||
]);
|
||||
const createRouter = (element: JSX.Element, path: string, current?: string) =>
|
||||
createMemoryRouter([{ path, element }], {
|
||||
initialEntries: [current ?? path],
|
||||
});
|
||||
|
||||
const userPlanFeatures: UserPlanFeatures = {
|
||||
hasProPlan: false,
|
||||
@ -38,60 +34,85 @@ const userPlanFeatures: UserPlanFeatures = {
|
||||
maxDomainsAllowedPerUser: 1,
|
||||
};
|
||||
|
||||
export const WithProjects: StoryFn<typeof Dashboard> = () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "0",
|
||||
createdAt: new Date().toString(),
|
||||
title: "My Project",
|
||||
domain: "domain.com",
|
||||
userId: "",
|
||||
isDeleted: false,
|
||||
isPublished: false,
|
||||
latestBuild: null,
|
||||
previewImageAsset: null,
|
||||
previewImageAssetId: "",
|
||||
latestBuildVirtual: null,
|
||||
marketplaceApprovalStatus: "UNLISTED" as const,
|
||||
} as DashboardProject,
|
||||
];
|
||||
const projects = [
|
||||
{
|
||||
id: "0",
|
||||
createdAt: new Date().toString(),
|
||||
title: "My Project",
|
||||
domain: "domain.com",
|
||||
userId: "",
|
||||
isDeleted: false,
|
||||
isPublished: false,
|
||||
latestBuild: null,
|
||||
previewImageAsset: null,
|
||||
previewImageAssetId: "",
|
||||
latestBuildVirtual: null,
|
||||
marketplaceApprovalStatus: "UNLISTED" as const,
|
||||
} as DashboardProject,
|
||||
];
|
||||
|
||||
const data = {
|
||||
user,
|
||||
templates: projects,
|
||||
userPlanFeatures,
|
||||
publisherHost: "https://wstd.work",
|
||||
projects,
|
||||
};
|
||||
|
||||
export const Welcome: StoryFn<typeof Dashboard> = () => {
|
||||
const router = createRouter(
|
||||
<Dashboard
|
||||
user={user}
|
||||
welcome={false}
|
||||
projects={projects}
|
||||
userPlanFeatures={userPlanFeatures}
|
||||
publisherHost={"https://wstd.work"}
|
||||
/>
|
||||
<>
|
||||
<DashboardSetup data={{ ...data, projects: [] }} />
|
||||
<Dashboard />
|
||||
</>,
|
||||
"/dashboard/templates"
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export const WithTemplates: StoryFn<typeof Dashboard> = () => {
|
||||
const templates = [
|
||||
{
|
||||
id: "0",
|
||||
createdAt: new Date().toString(),
|
||||
title: "My Project",
|
||||
domain: "domain.com",
|
||||
userId: "",
|
||||
isDeleted: false,
|
||||
isPublished: false,
|
||||
latestBuild: null,
|
||||
previewImageAsset: null,
|
||||
previewImageAssetId: "",
|
||||
latestBuildVirtual: null,
|
||||
marketplaceApprovalStatus: "UNLISTED" as const,
|
||||
} as DashboardProject,
|
||||
];
|
||||
export const Projects: StoryFn<typeof Dashboard> = () => {
|
||||
const router = createRouter(
|
||||
<Dashboard
|
||||
user={user}
|
||||
templates={templates}
|
||||
welcome
|
||||
userPlanFeatures={userPlanFeatures}
|
||||
publisherHost={"https://wstd.work"}
|
||||
/>
|
||||
<>
|
||||
<DashboardSetup data={data} />
|
||||
<Dashboard />
|
||||
</>,
|
||||
"/dashboard"
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export const Templates: StoryFn<typeof Dashboard> = () => {
|
||||
const router = createRouter(
|
||||
<>
|
||||
<DashboardSetup data={data} />
|
||||
<Dashboard />
|
||||
</>,
|
||||
"/dashboard/templates"
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export const Search: StoryFn<typeof Dashboard> = () => {
|
||||
const router = createRouter(
|
||||
<>
|
||||
<DashboardSetup data={data} />
|
||||
<Dashboard />
|
||||
</>,
|
||||
"/dashboard/search",
|
||||
"/dashboard/search?q=my"
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export const SearchNothingFound: StoryFn<typeof Dashboard> = () => {
|
||||
const router = createRouter(
|
||||
<>
|
||||
<DashboardSetup data={data} />
|
||||
<Dashboard />
|
||||
</>,
|
||||
"/dashboard/search",
|
||||
"/dashboard/search?q=notfound"
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
@ -10,19 +10,21 @@ import {
|
||||
globalCss,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { DashboardProject } from "@webstudio-is/dashboard";
|
||||
import { BodyIcon, ExtensionIcon } from "@webstudio-is/icons";
|
||||
import type { User } from "~/shared/db/user.server";
|
||||
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
|
||||
import { NavLink, useLocation, useRevalidator } from "@remix-run/react";
|
||||
import { atom } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { CloneProjectDialog } from "~/shared/clone-project";
|
||||
import { dashboardPath, templatesPath } from "~/shared/router-utils";
|
||||
import { dashboardPath } from "~/shared/router-utils";
|
||||
import { CollapsibleSection } from "~/builder/shared/collapsible-section";
|
||||
import { ProfileMenu } from "./profile-menu";
|
||||
import { Projects } from "./projects/projects";
|
||||
import { Templates } from "./templates/templates";
|
||||
import { Header } from "./shared/layout";
|
||||
import { help } from "~/shared/help";
|
||||
import { SearchResults } from "./search/search-results";
|
||||
import type { DashboardData } from "./shared/types";
|
||||
import { Search } from "./search/search-field";
|
||||
|
||||
const globalStyles = globalCss({
|
||||
body: {
|
||||
@ -33,7 +35,7 @@ const globalStyles = globalCss({
|
||||
const CloneProject = ({
|
||||
projectToClone,
|
||||
}: {
|
||||
projectToClone: DashboardProps["projectToClone"];
|
||||
projectToClone: DashboardData["projectToClone"];
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [isOpen, setIsOpen] = useState(projectToClone !== undefined);
|
||||
@ -51,20 +53,22 @@ const CloneProject = ({
|
||||
window.history.replaceState(currentState, "", location.pathname);
|
||||
}, [location.search, location.pathname]);
|
||||
|
||||
return projectToClone !== undefined ? (
|
||||
<CloneProjectDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
project={{
|
||||
id: projectToClone.id,
|
||||
title: projectToClone.title,
|
||||
}}
|
||||
authToken={projectToClone.authToken}
|
||||
onCreate={() => {
|
||||
revalidate();
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
if (projectToClone !== undefined) {
|
||||
return (
|
||||
<CloneProjectDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
project={{
|
||||
id: projectToClone.id,
|
||||
title: projectToClone.title,
|
||||
}}
|
||||
authToken={projectToClone.authToken}
|
||||
onCreate={() => {
|
||||
revalidate();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sidebarLinkStyle = css({
|
||||
@ -99,7 +103,7 @@ const NavigationItems = ({
|
||||
<List style={{ padding: 0, margin: 0 }}>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<ListItem asChild index={index}>
|
||||
<ListItem asChild index={index} key={index}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end
|
||||
@ -118,30 +122,47 @@ const NavigationItems = ({
|
||||
);
|
||||
};
|
||||
|
||||
type DashboardProps = {
|
||||
user: User;
|
||||
projects?: Array<DashboardProject>;
|
||||
templates?: Array<DashboardProject>;
|
||||
welcome: boolean;
|
||||
userPlanFeatures: UserPlanFeatures;
|
||||
publisherHost: string;
|
||||
projectToClone?: {
|
||||
authToken: string;
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
const $data = atom<DashboardData | undefined>();
|
||||
|
||||
export const DashboardSetup = ({ data }: { data: DashboardData }) => {
|
||||
$data.set(data);
|
||||
globalStyles();
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const Dashboard = ({
|
||||
user,
|
||||
projects,
|
||||
templates,
|
||||
welcome,
|
||||
userPlanFeatures,
|
||||
publisherHost,
|
||||
projectToClone,
|
||||
}: DashboardProps) => {
|
||||
globalStyles();
|
||||
const getView = (pathname: string, hasProjects: boolean) => {
|
||||
if (pathname === dashboardPath("search")) {
|
||||
return "search";
|
||||
}
|
||||
|
||||
if (hasProjects === false) {
|
||||
return "welcome";
|
||||
}
|
||||
|
||||
if (pathname === dashboardPath("templates")) {
|
||||
return "templates";
|
||||
}
|
||||
return "projects";
|
||||
};
|
||||
|
||||
export const Dashboard = () => {
|
||||
const data = useStore($data);
|
||||
const location = useLocation();
|
||||
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
user,
|
||||
userPlanFeatures,
|
||||
publisherHost,
|
||||
projectToClone,
|
||||
projects,
|
||||
templates,
|
||||
} = data;
|
||||
const hasProjects = projects.length > 0;
|
||||
const view = getView(location.pathname, hasProjects);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@ -160,26 +181,36 @@ export const Dashboard = ({
|
||||
<Header variant="aside">
|
||||
<ProfileMenu user={user} userPlanFeatures={userPlanFeatures} />
|
||||
</Header>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
css={{
|
||||
paddingInline: theme.spacing[7],
|
||||
paddingBottom: theme.spacing[7],
|
||||
}}
|
||||
>
|
||||
<Search />
|
||||
</Flex>
|
||||
<nav>
|
||||
<CollapsibleSection label="Workspace" fullWidth>
|
||||
<NavigationItems
|
||||
items={
|
||||
welcome
|
||||
view === "welcome" || hasProjects === false
|
||||
? [
|
||||
{
|
||||
to: templatesPath(),
|
||||
to: dashboardPath(),
|
||||
prefix: <ExtensionIcon />,
|
||||
children: "Welcome",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
to: dashboardPath(),
|
||||
to: dashboardPath("projects"),
|
||||
prefix: <BodyIcon />,
|
||||
children: "Projects",
|
||||
},
|
||||
{
|
||||
to: templatesPath(),
|
||||
to: dashboardPath("templates"),
|
||||
prefix: <ExtensionIcon />,
|
||||
children: "Starter templates",
|
||||
},
|
||||
@ -199,14 +230,16 @@ export const Dashboard = ({
|
||||
</CollapsibleSection>
|
||||
</nav>
|
||||
</Flex>
|
||||
{projects && (
|
||||
{view === "projects" && (
|
||||
<Projects
|
||||
projects={projects}
|
||||
hasProPlan={userPlanFeatures.hasProPlan}
|
||||
publisherHost={publisherHost}
|
||||
/>
|
||||
)}
|
||||
{templates && <Templates templates={templates} welcome={welcome} />}
|
||||
{view === "templates" && <Templates projects={templates} />}
|
||||
{view === "welcome" && <Templates projects={templates} welcome />}
|
||||
{view === "search" && <SearchResults {...data} />}
|
||||
</Flex>
|
||||
<CloneProject projectToClone={projectToClone} />
|
||||
<Toaster />
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { Flex, Text } from "@webstudio-is/design-system";
|
||||
import { CreateProject } from "./project-dialogs";
|
||||
|
||||
export const EmptyState = () => (
|
||||
<Flex align="center" justify="center" direction="column" gap="6">
|
||||
<Text variant="brandMediumTitle" as="h1" align="center">
|
||||
What will you create?
|
||||
</Text>
|
||||
<CreateProject buttonText="Create First Project" />
|
||||
</Flex>
|
||||
);
|
@ -1,4 +1,4 @@
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@ -96,47 +96,6 @@ const Menu = ({
|
||||
);
|
||||
};
|
||||
|
||||
// @todo use List/ListItem instead
|
||||
export const useProjectCard = () => {
|
||||
const thumbnailRef = useRef<HTMLAnchorElement & HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
const elements: Array<HTMLElement> = Array.from(
|
||||
event.currentTarget.querySelectorAll(`[tabIndex='-1']`)
|
||||
);
|
||||
const currentIndex = elements.indexOf(
|
||||
document.activeElement as HTMLElement
|
||||
);
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
// Only open project on enter when the project card container was focused,
|
||||
// otherwise we will always open project, even when a menu was pressed.
|
||||
if (event.currentTarget === document.activeElement) {
|
||||
thumbnailRef.current?.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowUp":
|
||||
case "ArrowRight": {
|
||||
const nextElement = elements.at(currentIndex + 1) ?? elements[0];
|
||||
nextElement?.focus();
|
||||
break;
|
||||
}
|
||||
case "ArrowDown":
|
||||
case "ArrowLeft": {
|
||||
const nextElement = elements.at(currentIndex - 1) ?? elements[0];
|
||||
nextElement?.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
thumbnailRef,
|
||||
handleKeyDown,
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
@ -163,12 +122,12 @@ export const ProjectCard = ({
|
||||
},
|
||||
hasProPlan,
|
||||
publisherHost,
|
||||
...props
|
||||
}: ProjectCardProps) => {
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const { thumbnailRef, handleKeyDown } = useProjectCard();
|
||||
const handleCloneProject = useCloneProject(id);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
@ -195,7 +154,7 @@ export const ProjectCard = ({
|
||||
const linkPath = builderUrl({ origin: window.origin, projectId: id });
|
||||
|
||||
return (
|
||||
<Card hidden={isHidden} tabIndex={0} onKeyDown={handleKeyDown}>
|
||||
<Card hidden={isHidden} {...props}>
|
||||
<CardContent
|
||||
css={{
|
||||
background: theme.colors.brandBackgroundProjectCardBack,
|
||||
@ -219,17 +178,9 @@ export const ProjectCard = ({
|
||||
/>
|
||||
|
||||
{previewImageAsset ? (
|
||||
<ThumbnailLinkWithImage
|
||||
to={linkPath}
|
||||
name={previewImageAsset.name}
|
||||
ref={thumbnailRef}
|
||||
/>
|
||||
<ThumbnailLinkWithImage to={linkPath} name={previewImageAsset.name} />
|
||||
) : (
|
||||
<ThumbnailLinkWithAbbr
|
||||
title={title}
|
||||
to={linkPath}
|
||||
ref={thumbnailRef}
|
||||
/>
|
||||
<ThumbnailLinkWithAbbr title={title} to={linkPath} />
|
||||
)}
|
||||
{isTransitioning && <Spinner delay={0} />}
|
||||
</CardContent>
|
||||
|
@ -1,21 +1,54 @@
|
||||
import { Flex, Grid, Text, rawTheme, theme } from "@webstudio-is/design-system";
|
||||
import {
|
||||
Flex,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
Text,
|
||||
rawTheme,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { DashboardProject } from "@webstudio-is/dashboard";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import { ProjectCard } from "./project-card";
|
||||
import { CreateProject } from "./project-dialogs";
|
||||
import { Header, Main } from "../shared/layout";
|
||||
|
||||
export const ProjectsGrid = ({
|
||||
projects,
|
||||
hasProPlan,
|
||||
publisherHost,
|
||||
}: ProjectsProps) => {
|
||||
return (
|
||||
<List asChild>
|
||||
<Grid
|
||||
gap="6"
|
||||
css={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
|
||||
paddingBottom: theme.spacing[13],
|
||||
}}
|
||||
>
|
||||
{projects.map((project) => {
|
||||
return (
|
||||
<ListItem index={0} key={project.id} asChild>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
hasProPlan={hasProPlan}
|
||||
publisherHost={publisherHost}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
type ProjectsProps = {
|
||||
projects: Array<DashboardProject>;
|
||||
hasProPlan: boolean;
|
||||
publisherHost: string;
|
||||
};
|
||||
|
||||
export const Projects = ({
|
||||
projects,
|
||||
hasProPlan,
|
||||
publisherHost,
|
||||
}: ProjectsProps) => {
|
||||
export const Projects = (props: ProjectsProps) => {
|
||||
return (
|
||||
<Main>
|
||||
<Header variant="main">
|
||||
@ -29,30 +62,9 @@ export const Projects = ({
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
css={{
|
||||
paddingInline: theme.spacing[13],
|
||||
paddingTop: projects.length === 0 ? "20vh" : 0,
|
||||
}}
|
||||
css={{ paddingInline: theme.spacing[13] }}
|
||||
>
|
||||
{projects.length === 0 && <EmptyState />}
|
||||
<Grid
|
||||
gap="6"
|
||||
css={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
|
||||
paddingBottom: theme.spacing[13],
|
||||
}}
|
||||
>
|
||||
{projects.map((project) => {
|
||||
return (
|
||||
<ProjectCard
|
||||
project={project}
|
||||
key={project.id}
|
||||
hasProPlan={hasProPlan}
|
||||
publisherHost={publisherHost}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<ProjectsGrid {...props} />
|
||||
</Flex>
|
||||
</Main>
|
||||
);
|
||||
|
9
apps/builder/app/dashboard/search/nothing-found.tsx
Normal file
9
apps/builder/app/dashboard/search/nothing-found.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Flex, Text } from "@webstudio-is/design-system";
|
||||
|
||||
export const NothingFound = () => (
|
||||
<Flex align="center" justify="center" direction="column" gap="6">
|
||||
<Text variant="brandSectionTitle" as="h1" align="center">
|
||||
Nothing found 🙁
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
47
apps/builder/app/dashboard/search/search-field.tsx
Normal file
47
apps/builder/app/dashboard/search/search-field.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { SearchField } from "@webstudio-is/design-system";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { dashboardPath } from "~/shared/router-utils";
|
||||
|
||||
export const Search = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const handleAbortSearch = () => {
|
||||
// When user cancels the search, we try to return to the last path they were on.
|
||||
if (location.state?.previousPathname) {
|
||||
return navigate(location.state.previousPathname);
|
||||
}
|
||||
navigate(dashboardPath("projects"));
|
||||
};
|
||||
const isSearchRoute = location.pathname === dashboardPath("search");
|
||||
|
||||
return (
|
||||
<SearchField
|
||||
value={searchParams.get("q") ?? undefined}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value.trim();
|
||||
if (value === "") {
|
||||
handleAbortSearch();
|
||||
return;
|
||||
}
|
||||
if (isSearchRoute === false) {
|
||||
navigate(
|
||||
{
|
||||
pathname: dashboardPath("search"),
|
||||
search: `?q=${value}`,
|
||||
},
|
||||
// Remember the last path to return to on abort
|
||||
{
|
||||
state: { previousPathname: location.pathname },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSearchParams({ q: value }, { replace: true });
|
||||
}}
|
||||
onAbort={handleAbortSearch}
|
||||
autoFocus
|
||||
placeholder="Search for anything"
|
||||
/>
|
||||
);
|
||||
};
|
81
apps/builder/app/dashboard/search/search-results.tsx
Normal file
81
apps/builder/app/dashboard/search/search-results.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useMemo } from "react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Flex, Separator, Text, theme } from "@webstudio-is/design-system";
|
||||
import type { DashboardProject } from "@webstudio-is/dashboard";
|
||||
import { ProjectsGrid } from "../projects/projects";
|
||||
import { Header, Main } from "../shared/layout";
|
||||
import type { DashboardData } from "../shared/types";
|
||||
import { NothingFound } from "./nothing-found";
|
||||
import { TemplatesGrid } from "../templates/templates";
|
||||
|
||||
type SearchResults = {
|
||||
projects: Array<DashboardProject>;
|
||||
templates: Array<DashboardProject>;
|
||||
};
|
||||
|
||||
const initialSearchResults: SearchResults = {
|
||||
templates: [],
|
||||
projects: [],
|
||||
} as const;
|
||||
|
||||
export const SearchResults = (props: DashboardData) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { projects, templates, userPlanFeatures, publisherHost } = props;
|
||||
const search = searchParams.get("q");
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!search || !projects || !templates) {
|
||||
return initialSearchResults;
|
||||
}
|
||||
const keys = ["title", "domain"];
|
||||
return {
|
||||
projects: matchSorter(projects, search, { keys }),
|
||||
templates: matchSorter(templates, search, { keys }),
|
||||
};
|
||||
}, [projects, templates, search]);
|
||||
|
||||
const nothingFound =
|
||||
results.projects.length === 0 && results.templates.length === 0;
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<Header variant="main">
|
||||
<Text variant="brandRegular">
|
||||
Search results for <b>"{search}"</b>
|
||||
</Text>
|
||||
</Header>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
css={{
|
||||
paddingInline: theme.spacing[13],
|
||||
paddingTop: nothingFound ? "20vh" : 0,
|
||||
}}
|
||||
>
|
||||
{nothingFound && <NothingFound />}
|
||||
{results.projects.length > 0 && (
|
||||
<>
|
||||
<Text variant="brandSectionTitle" as="h2">
|
||||
Projects
|
||||
</Text>
|
||||
<ProjectsGrid
|
||||
projects={results.projects}
|
||||
hasProPlan={userPlanFeatures.hasProPlan}
|
||||
publisherHost={publisherHost}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{results.templates.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text variant="brandSectionTitle" as="h2">
|
||||
Templates
|
||||
</Text>
|
||||
<TemplatesGrid projects={results.templates} />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Main>
|
||||
);
|
||||
};
|
@ -21,7 +21,7 @@ const cardStyle = css({
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
outline: "none",
|
||||
"&:focus-within": {
|
||||
"&:focus-within, &[aria-selected=true]": {
|
||||
[borderColorVar]: theme.colors.borderFocus,
|
||||
},
|
||||
});
|
||||
|
16
apps/builder/app/dashboard/shared/types.ts
Normal file
16
apps/builder/app/dashboard/shared/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { DashboardProject } from "@webstudio-is/dashboard";
|
||||
import type { User } from "~/shared/db/user.server";
|
||||
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
|
||||
|
||||
export type DashboardData = {
|
||||
user: User;
|
||||
projects: Array<DashboardProject>;
|
||||
templates: Array<DashboardProject>;
|
||||
userPlanFeatures: UserPlanFeatures;
|
||||
publisherHost: string;
|
||||
projectToClone?: {
|
||||
authToken: string;
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
@ -5,18 +5,16 @@ import { builderUrl } from "~/shared/router-utils";
|
||||
import { Card, CardContent, CardFooter } from "../shared/card";
|
||||
import { ThumbnailWithAbbr, ThumbnailWithImage } from "../shared/thumbnail";
|
||||
import { CloneProjectDialog } from "~/shared/clone-project";
|
||||
import { useProjectCard } from "../projects/project-card";
|
||||
|
||||
type TemplateCardProps = {
|
||||
project: DashboardProject;
|
||||
};
|
||||
|
||||
export const TemplateCard = ({ project }: TemplateCardProps) => {
|
||||
const { thumbnailRef, handleKeyDown } = useProjectCard();
|
||||
export const TemplateCard = ({ project, ...props }: TemplateCardProps) => {
|
||||
const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
|
||||
const { title, previewImageAsset } = project;
|
||||
return (
|
||||
<Card tabIndex={0} onKeyDown={handleKeyDown}>
|
||||
<Card {...props}>
|
||||
<CardContent
|
||||
css={{
|
||||
background: theme.colors.brandBackgroundProjectCardBack,
|
||||
@ -26,7 +24,6 @@ export const TemplateCard = ({ project }: TemplateCardProps) => {
|
||||
{previewImageAsset ? (
|
||||
<ThumbnailWithImage
|
||||
name={previewImageAsset.name}
|
||||
ref={thumbnailRef}
|
||||
onClick={() => {
|
||||
setIsDuplicateDialogOpen(true);
|
||||
}}
|
||||
@ -34,7 +31,6 @@ export const TemplateCard = ({ project }: TemplateCardProps) => {
|
||||
) : (
|
||||
<ThumbnailWithAbbr
|
||||
title={title}
|
||||
ref={thumbnailRef}
|
||||
onClick={() => {
|
||||
setIsDuplicateDialogOpen(true);
|
||||
}}
|
||||
|
@ -1,15 +1,49 @@
|
||||
import { Flex, Grid, Text, rawTheme, theme } from "@webstudio-is/design-system";
|
||||
import {
|
||||
Flex,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
Text,
|
||||
rawTheme,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { DashboardProject } from "@webstudio-is/dashboard";
|
||||
import { Header, Main } from "../shared/layout";
|
||||
import { CreateProject } from "../projects/project-dialogs";
|
||||
import { TemplateCard } from "./template-card";
|
||||
|
||||
type ProjectsProps = {
|
||||
templates: Array<DashboardProject>;
|
||||
welcome: boolean;
|
||||
export const TemplatesGrid = ({
|
||||
projects,
|
||||
}: {
|
||||
projects: Array<DashboardProject>;
|
||||
}) => {
|
||||
return (
|
||||
<List asChild>
|
||||
<Grid
|
||||
gap="6"
|
||||
css={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
|
||||
paddingBottom: theme.spacing[13],
|
||||
}}
|
||||
>
|
||||
{projects.map((project) => {
|
||||
return (
|
||||
<ListItem index={0} key={project.id} asChild>
|
||||
<TemplateCard project={project} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const Templates = ({ templates, welcome }: ProjectsProps) => {
|
||||
type ProjectsProps = {
|
||||
projects: Array<DashboardProject>;
|
||||
welcome?: boolean;
|
||||
};
|
||||
|
||||
export const Templates = ({ projects, welcome = false }: ProjectsProps) => {
|
||||
return (
|
||||
<Main>
|
||||
<Header variant="main">
|
||||
@ -20,25 +54,13 @@ export const Templates = ({ templates, welcome }: ProjectsProps) => {
|
||||
<CreateProject />
|
||||
</Flex>
|
||||
</Header>
|
||||
{templates.length > 0 && (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
css={{ paddingInline: theme.spacing[13] }}
|
||||
>
|
||||
<Grid
|
||||
gap="6"
|
||||
css={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
|
||||
paddingBottom: theme.spacing[13],
|
||||
}}
|
||||
>
|
||||
{templates.map((project) => {
|
||||
return <TemplateCard project={project} key={project.id} />;
|
||||
})}
|
||||
</Grid>
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
css={{ paddingInline: theme.spacing[13] }}
|
||||
>
|
||||
<TemplatesGrid projects={projects} />
|
||||
</Flex>
|
||||
</Main>
|
||||
);
|
||||
};
|
||||
|
@ -1,92 +1,16 @@
|
||||
import { lazy } from "react";
|
||||
import { useLoaderData, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
|
||||
import { builderUrl, templatesPath } from "~/shared/router-utils";
|
||||
import env from "~/env/env.server";
|
||||
import { ClientOnly } from "~/shared/client-only";
|
||||
import { createCallerFactory } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { preconnect, prefetchDNS } from "react-dom";
|
||||
import {
|
||||
getProjectToClone,
|
||||
loadDashboardData,
|
||||
} from "~/shared/router-utils/dashboard";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { allowedDestinations } from "~/services/destinations.server";
|
||||
import { redirect } from "react-router-dom";
|
||||
export { ErrorBoundary } from "~/shared/error/error-boundary";
|
||||
|
||||
const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter);
|
||||
|
||||
export const meta = () => {
|
||||
const metas: ReturnType<MetaFunction> = [];
|
||||
|
||||
metas.push({ title: "Webstudio Dashboard | Projects" });
|
||||
|
||||
return metas;
|
||||
};
|
||||
|
||||
/**
|
||||
* When deleting/adding a project, then navigating to a new project and pressing the back button,
|
||||
* the dashboard page may display stale data because it’s being retrieved from the browser’s back/forward cache (bfcache).
|
||||
*
|
||||
* https://web.dev/articles/bfcache
|
||||
*
|
||||
*/
|
||||
export const headers = () => {
|
||||
return {
|
||||
"Cache-Control": "no-store",
|
||||
};
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
// CSRF token checks are not necessary for dashboard-only pages.
|
||||
// All requests from the builder or canvas app are safeguarded either by preventCrossOriginCookie for fetch requests
|
||||
// or by allowedDestinations for iframe requests.
|
||||
preventCrossOriginCookie(request);
|
||||
allowedDestinations(request, ["document", "empty"]);
|
||||
|
||||
const { context, user, userPlanFeatures, origin } =
|
||||
await loadDashboardData(request);
|
||||
const projects = await dashboardProjectCaller(context).findMany({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw redirect(templatesPath());
|
||||
}
|
||||
|
||||
const projectToClone = await getProjectToClone(request, context);
|
||||
|
||||
return {
|
||||
user,
|
||||
projects,
|
||||
welcome: false,
|
||||
userPlanFeatures,
|
||||
publisherHost: env.PUBLISHER_HOST,
|
||||
origin,
|
||||
projectToClone,
|
||||
};
|
||||
};
|
||||
|
||||
const Dashboard = lazy(async () => {
|
||||
const { Dashboard } = await import("~/dashboard/index.client");
|
||||
return { default: Dashboard };
|
||||
});
|
||||
|
||||
const DashboardRoute = () => {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
data.projects.slice(0, 5).forEach((project) => {
|
||||
prefetchDNS(builderUrl({ projectId: project.id, origin: data.origin }));
|
||||
});
|
||||
data.projects.slice(0, 5).forEach((project) => {
|
||||
preconnect(builderUrl({ projectId: project.id, origin: data.origin }));
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
<Dashboard {...data} />
|
||||
<Dashboard />
|
||||
</ClientOnly>
|
||||
);
|
||||
};
|
||||
|
27
apps/builder/app/routes/_ui.dashboard.search.tsx
Normal file
27
apps/builder/app/routes/_ui.dashboard.search.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { lazy } from "react";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { ClientOnly } from "~/shared/client-only";
|
||||
export { ErrorBoundary } from "~/shared/error/error-boundary";
|
||||
|
||||
export const meta = () => {
|
||||
const metas: ReturnType<MetaFunction> = [];
|
||||
|
||||
metas.push({ title: "Webstudio Dashboard | Search" });
|
||||
|
||||
return metas;
|
||||
};
|
||||
|
||||
const Dashboard = lazy(async () => {
|
||||
const { Dashboard } = await import("~/dashboard/index.client");
|
||||
return { default: Dashboard };
|
||||
});
|
||||
|
||||
const DashboardRoute = () => {
|
||||
return (
|
||||
<ClientOnly>
|
||||
<Dashboard />
|
||||
</ClientOnly>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardRoute;
|
@ -1,67 +1,25 @@
|
||||
import { lazy } from "react";
|
||||
import { useLoaderData, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
|
||||
import env from "~/env/env.server";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { ClientOnly } from "~/shared/client-only";
|
||||
import { createCallerFactory } from "@webstudio-is/trpc-interface/index.server";
|
||||
import {
|
||||
getProjectToClone,
|
||||
loadDashboardData,
|
||||
} from "~/shared/router-utils/dashboard";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { allowedDestinations } from "~/services/destinations.server";
|
||||
export { ErrorBoundary } from "~/shared/error/error-boundary";
|
||||
|
||||
const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter);
|
||||
|
||||
export const meta = () => {
|
||||
const metas: ReturnType<MetaFunction> = [];
|
||||
|
||||
metas.push({ title: "Webstudio Dashboard | Starter templates" });
|
||||
metas.push({ title: "Webstudio Dashboard | Templates" });
|
||||
|
||||
return metas;
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
// CSRF token checks are not necessary for dashboard-only pages.
|
||||
// All requests from the builder or canvas app are safeguarded either by preventCrossOriginCookie for fetch requests
|
||||
// or by allowedDestinations for iframe requests.
|
||||
preventCrossOriginCookie(request);
|
||||
allowedDestinations(request, ["document", "empty"]);
|
||||
|
||||
const { context, user, userPlanFeatures, origin } =
|
||||
await loadDashboardData(request);
|
||||
const projectToClone = await getProjectToClone(request, context);
|
||||
const templates = await dashboardProjectCaller(context).findManyByIds({
|
||||
projectIds: env.PROJECT_TEMPLATES,
|
||||
});
|
||||
const hasProjects = await dashboardProjectCaller(context).hasAny({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
templates,
|
||||
welcome: hasProjects === false,
|
||||
userPlanFeatures,
|
||||
publisherHost: env.PUBLISHER_HOST,
|
||||
origin,
|
||||
projectToClone,
|
||||
};
|
||||
};
|
||||
|
||||
const Dashboard = lazy(async () => {
|
||||
const { Dashboard } = await import("~/dashboard/index.client");
|
||||
return { default: Dashboard };
|
||||
});
|
||||
|
||||
const DashboardRoute = () => {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
<Dashboard {...data} />
|
||||
<Dashboard />
|
||||
</ClientOnly>
|
||||
);
|
||||
};
|
||||
|
197
apps/builder/app/routes/_ui.dashboard.tsx
Normal file
197
apps/builder/app/routes/_ui.dashboard.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { lazy } from "react";
|
||||
import { preconnect, prefetchDNS } from "react-dom";
|
||||
import {
|
||||
Outlet,
|
||||
redirect,
|
||||
type ShouldRevalidateFunction,
|
||||
} from "react-router-dom";
|
||||
import { useLoaderData, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
createCallerFactory,
|
||||
AuthorizationError,
|
||||
type AppContext,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
|
||||
import { db } from "@webstudio-is/project/index.server";
|
||||
import { parseBuilderUrl } from "@webstudio-is/http-client";
|
||||
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
|
||||
import { builderUrl, isDashboard, loginPath } from "~/shared/router-utils";
|
||||
import env from "~/env/env.server";
|
||||
import { ClientOnly } from "~/shared/client-only";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { allowedDestinations } from "~/services/destinations.server";
|
||||
export { ErrorBoundary } from "~/shared/error/error-boundary";
|
||||
import { findAuthenticatedUser } from "~/services/auth.server";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
|
||||
export const meta = () => {
|
||||
const metas: ReturnType<MetaFunction> = [];
|
||||
|
||||
metas.push({ title: "Webstudio Dashboard | Projects" });
|
||||
|
||||
return metas;
|
||||
};
|
||||
|
||||
/**
|
||||
* When deleting/adding a project, then navigating to a new project and pressing the back button,
|
||||
* the dashboard page may display stale data because it’s being retrieved from the browser’s back/forward cache (bfcache).
|
||||
*
|
||||
* https://web.dev/articles/bfcache
|
||||
*
|
||||
*/
|
||||
export const headers = () => {
|
||||
return {
|
||||
"Cache-Control": "no-store",
|
||||
};
|
||||
};
|
||||
|
||||
const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter);
|
||||
|
||||
const loadDashboardData = async (request: Request) => {
|
||||
if (false === isDashboard(request)) {
|
||||
throw new Response("Not Found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await findAuthenticatedUser(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (user === null) {
|
||||
throw redirect(
|
||||
loginPath({
|
||||
returnTo: `${url.pathname}${url.search}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const context = await createContext(request);
|
||||
|
||||
if (context.authorization.type !== "user") {
|
||||
throw new AuthorizationError("You must be logged in to access this page");
|
||||
}
|
||||
|
||||
const { userPlanFeatures } = context;
|
||||
|
||||
if (userPlanFeatures === undefined) {
|
||||
throw new Response("User plan features are not defined", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const { sourceOrigin } = parseBuilderUrl(request.url);
|
||||
|
||||
const projects = await dashboardProjectCaller(context).findMany({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const templates = await dashboardProjectCaller(context).findManyByIds({
|
||||
projectIds: env.PROJECT_TEMPLATES,
|
||||
});
|
||||
|
||||
return {
|
||||
context,
|
||||
user,
|
||||
origin: sourceOrigin,
|
||||
userPlanFeatures,
|
||||
projects,
|
||||
templates,
|
||||
};
|
||||
};
|
||||
|
||||
const getProjectToClone = async (request: Request, context: AppContext) => {
|
||||
const url = new URL(request.url);
|
||||
const projectToCloneAuthToken = url.searchParams.get(
|
||||
"projectToCloneAuthToken"
|
||||
);
|
||||
|
||||
if (
|
||||
// Only on navigation requests
|
||||
request.headers.get("sec-fetch-mode") !== "navigate" ||
|
||||
projectToCloneAuthToken === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone project
|
||||
const token = await authDb.getTokenInfo(projectToCloneAuthToken, context);
|
||||
if (token.canClone === false) {
|
||||
throw new AuthorizationError("You don't have access to clone this project");
|
||||
}
|
||||
|
||||
const project = await db.project.loadById(
|
||||
token.projectId,
|
||||
await context.createTokenContext(projectToCloneAuthToken)
|
||||
);
|
||||
|
||||
return {
|
||||
id: token.projectId,
|
||||
authToken: projectToCloneAuthToken,
|
||||
title: project.title,
|
||||
};
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
// CSRF token checks are not necessary for dashboard-only pages.
|
||||
// All requests from the builder or canvas app are safeguarded either by preventCrossOriginCookie for fetch requests
|
||||
// or by allowedDestinations for iframe requests.
|
||||
preventCrossOriginCookie(request);
|
||||
allowedDestinations(request, ["document", "empty"]);
|
||||
|
||||
const { context, user, userPlanFeatures, origin, projects, templates } =
|
||||
await loadDashboardData(request);
|
||||
|
||||
const projectToClone = await getProjectToClone(request, context);
|
||||
|
||||
return {
|
||||
user,
|
||||
projects,
|
||||
templates,
|
||||
userPlanFeatures,
|
||||
publisherHost: env.PUBLISHER_HOST,
|
||||
origin,
|
||||
projectToClone,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
defaultShouldRevalidate,
|
||||
currentUrl,
|
||||
nextUrl,
|
||||
}) => {
|
||||
// We have the entire data on the client, so we don't need to revalidate when
|
||||
// URL is changing.
|
||||
if (currentUrl.href !== nextUrl.href) {
|
||||
return false;
|
||||
}
|
||||
// When .revalidate() was called explicitely without chaning the URL,
|
||||
// `defaultShouldRevalidate` will be true
|
||||
return defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
const DashboardSetup = lazy(async () => {
|
||||
const { DashboardSetup } = await import("~/dashboard/index.client");
|
||||
return { default: DashboardSetup };
|
||||
});
|
||||
|
||||
const DashboardRoute = () => {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
data.projects.slice(0, 5).forEach((project) => {
|
||||
prefetchDNS(builderUrl({ projectId: project.id, origin: data.origin }));
|
||||
});
|
||||
data.projects.slice(0, 5).forEach((project) => {
|
||||
preconnect(builderUrl({ projectId: project.id, origin: data.origin }));
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
<DashboardSetup data={data} />
|
||||
<Outlet />
|
||||
</ClientOnly>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardRoute;
|
@ -1,86 +0,0 @@
|
||||
import {
|
||||
AuthorizationError,
|
||||
type AppContext,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
|
||||
import { db } from "@webstudio-is/project/index.server";
|
||||
import { parseBuilderUrl } from "@webstudio-is/http-client";
|
||||
import { findAuthenticatedUser } from "~/services/auth.server";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
export { ErrorBoundary } from "~/shared/error/error-boundary";
|
||||
import { redirect } from "~/services/no-store-redirect";
|
||||
import { loginPath } from "./path-utils";
|
||||
import { isDashboard } from "./is-canvas";
|
||||
|
||||
export const loadDashboardData = async (request: Request) => {
|
||||
if (false === isDashboard(request)) {
|
||||
throw new Response("Not Found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await findAuthenticatedUser(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (user === null) {
|
||||
throw redirect(
|
||||
loginPath({
|
||||
returnTo: `${url.pathname}${url.search}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const context = await createContext(request);
|
||||
|
||||
if (context.authorization.type !== "user") {
|
||||
throw new AuthorizationError("You must be logged in to access this page");
|
||||
}
|
||||
|
||||
const { userPlanFeatures } = context;
|
||||
|
||||
if (userPlanFeatures === undefined) {
|
||||
throw new Response("User plan features are not defined", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const { sourceOrigin } = parseBuilderUrl(request.url);
|
||||
|
||||
return { context, user, origin: sourceOrigin, userPlanFeatures };
|
||||
};
|
||||
|
||||
export const getProjectToClone = async (
|
||||
request: Request,
|
||||
context: AppContext
|
||||
) => {
|
||||
const url = new URL(request.url);
|
||||
const projectToCloneAuthToken = url.searchParams.get(
|
||||
"projectToCloneAuthToken"
|
||||
);
|
||||
|
||||
if (
|
||||
// Only on navigation requests
|
||||
request.headers.get("sec-fetch-mode") !== "navigate" ||
|
||||
projectToCloneAuthToken === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone project
|
||||
const token = await authDb.getTokenInfo(projectToCloneAuthToken, context);
|
||||
if (token.canClone === false) {
|
||||
throw new AuthorizationError("You don't have access to clone this project");
|
||||
}
|
||||
|
||||
const project = await db.project.loadById(
|
||||
token.projectId,
|
||||
await context.createTokenContext(projectToCloneAuthToken)
|
||||
);
|
||||
|
||||
return {
|
||||
id: token.projectId,
|
||||
authToken: projectToCloneAuthToken,
|
||||
title: project.title,
|
||||
};
|
||||
};
|
@ -64,12 +64,13 @@ export const builderUrl = (props: {
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const dashboardPath = () => {
|
||||
return "/dashboard";
|
||||
};
|
||||
|
||||
export const templatesPath = () => {
|
||||
return "/dashboard/templates";
|
||||
export const dashboardPath = (
|
||||
view: "templates" | "search" | "projects" = "projects"
|
||||
) => {
|
||||
if (view === "projects") {
|
||||
return `/dashboard`;
|
||||
}
|
||||
return `/dashboard/${view}`;
|
||||
};
|
||||
|
||||
export const dashboardUrl = (props: { origin: string }) => {
|
||||
|
@ -68,30 +68,3 @@ export const findManyByIds = async (
|
||||
| "marketplaceApprovalStatus"
|
||||
>[];
|
||||
};
|
||||
|
||||
export const hasAny = async (userId: string, context: AppContext) => {
|
||||
if (context.authorization.type !== "user") {
|
||||
throw new AuthorizationError(
|
||||
"Only logged in users can view the project list"
|
||||
);
|
||||
}
|
||||
|
||||
if (userId !== context.authorization.userId) {
|
||||
throw new AuthorizationError(
|
||||
"Only the project owner can view the project list"
|
||||
);
|
||||
}
|
||||
|
||||
const data = await context.postgrest.client
|
||||
.from("DashboardProject")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.eq("userId", userId)
|
||||
.eq("isDeleted", false);
|
||||
|
||||
if (data.error) {
|
||||
throw data.error;
|
||||
}
|
||||
|
||||
return data.data.length > 0;
|
||||
};
|
||||
|
@ -20,12 +20,6 @@ const projectRouter = router({
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await db.findManyByIds(input.projectIds, ctx);
|
||||
}),
|
||||
|
||||
hasAny: procedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await db.hasAny(input.userId, ctx);
|
||||
}),
|
||||
});
|
||||
|
||||
export const dashboardProjectRouter = mergeRouters(
|
||||
|
@ -34,11 +34,11 @@ const AbortButton = styled(SmallIconButton, {
|
||||
|
||||
const SearchFieldBase: ForwardRefRenderFunction<
|
||||
HTMLInputElement,
|
||||
ComponentProps<typeof InputField> & { onCancel?: () => void }
|
||||
ComponentProps<typeof InputField> & { onAbort?: () => void }
|
||||
> = (props, ref) => {
|
||||
const {
|
||||
onChange,
|
||||
onCancel,
|
||||
onAbort,
|
||||
value: propsValue = "",
|
||||
onKeyDown,
|
||||
...rest
|
||||
@ -50,7 +50,7 @@ const SearchFieldBase: ForwardRefRenderFunction<
|
||||
}, [propsValue]);
|
||||
const handleCancel = () => {
|
||||
setValue("");
|
||||
onCancel?.();
|
||||
onAbort?.();
|
||||
};
|
||||
return (
|
||||
<InputField
|
||||
@ -97,13 +97,13 @@ export const SearchField = forwardRef(SearchFieldBase);
|
||||
type UseSearchFieldKeys = {
|
||||
onMove: (event: { direction: "next" | "previous" | "current" }) => void;
|
||||
onChange?: FormEventHandler<HTMLInputElement>;
|
||||
onCancel?: () => void;
|
||||
onAbort?: () => void;
|
||||
};
|
||||
|
||||
export const useSearchFieldKeys = ({
|
||||
onMove,
|
||||
onChange,
|
||||
onCancel,
|
||||
onAbort,
|
||||
}: UseSearchFieldKeys) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const handleKeyDown: KeyboardEventHandler = ({ code }) => {
|
||||
@ -126,12 +126,12 @@ export const useSearchFieldKeys = ({
|
||||
|
||||
const handleCancel = () => {
|
||||
setSearch("");
|
||||
onCancel?.();
|
||||
onAbort?.();
|
||||
};
|
||||
|
||||
return {
|
||||
value: search,
|
||||
onCancel: handleCancel,
|
||||
onAbort: handleCancel,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
};
|
||||
|
Reference in New Issue
Block a user