diff --git a/apps/builder/app/builder/features/pages/page-settings.stories.tsx b/apps/builder/app/builder/features/pages/page-settings.stories.tsx index b354f13190ef..cd66290aecef 100644 --- a/apps/builder/app/builder/features/pages/page-settings.stories.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.stories.tsx @@ -63,6 +63,7 @@ $project.set({ isDeleted: false, userId: "userId", domain: "new-2x9tcd", + tags: [], marketplaceApprovalStatus: "UNLISTED", diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx index 3e8b598bc745..a8f5ca2ac499 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -214,7 +214,7 @@ const InvalidCollectionDataStub = forwardRef< event.stopPropagation()} > diff --git a/apps/builder/app/dashboard/dashboard.stories.tsx b/apps/builder/app/dashboard/dashboard.stories.tsx index e6c93a38095c..6de8d838a6fc 100644 --- a/apps/builder/app/dashboard/dashboard.stories.tsx +++ b/apps/builder/app/dashboard/dashboard.stories.tsx @@ -18,6 +18,7 @@ const user = { username: "Taylor", teamId: null, provider: "github", + projectsTags: [], }; const createRouter = (element: JSX.Element, path: string, current?: string) => @@ -49,6 +50,7 @@ const projects = [ previewImageAssetId: "", latestBuildVirtual: null, marketplaceApprovalStatus: "UNLISTED" as const, + tags: [], } as DashboardProject, ]; diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx index a96c6bbe524a..28ab6aaffc93 100644 --- a/apps/builder/app/dashboard/dashboard.tsx +++ b/apps/builder/app/dashboard/dashboard.tsx @@ -256,6 +256,7 @@ export const Dashboard = () => { projects={projects} hasProPlan={userPlanFeatures.hasProPlan} publisherHost={publisherHost} + projectsTags={user.projectsTags} /> )} {view === "templates" && } diff --git a/apps/builder/app/dashboard/projects/colors.ts b/apps/builder/app/dashboard/projects/colors.ts new file mode 100644 index 000000000000..9b05f55fd9b6 --- /dev/null +++ b/apps/builder/app/dashboard/projects/colors.ts @@ -0,0 +1,6 @@ +export const colors = Array.from({ length: 50 }, (_, i) => { + const l = 55 + (i % 3) * 3; // Reduced variation in lightness (55-61%) to lower contrast + const c = 0.14 + (i % 2) * 0.02; // Reduced variation in chroma (0.14-0.16) for balance + const h = (i * 137.5) % 360; // Golden angle for pleasing hue distribution + return `oklch(${l}% ${c.toFixed(2)} ${h.toFixed(1)})`; +}); diff --git a/apps/builder/app/dashboard/projects/project-card.tsx b/apps/builder/app/dashboard/projects/project-card.tsx index 8318b44840ab..82d796cf9c55 100644 --- a/apps/builder/app/dashboard/projects/project-card.tsx +++ b/apps/builder/app/dashboard/projects/project-card.tsx @@ -30,6 +30,8 @@ import { } from "../shared/thumbnail"; import { Spinner } from "../shared/spinner"; import { Card, CardContent, CardFooter } from "../shared/card"; +import type { User } from "~/shared/db/user.server"; +import { TagsDialog } from "./tags"; const infoIconStyle = css({ flexShrink: 0 }); @@ -64,12 +66,14 @@ const Menu = ({ onRename, onDuplicate, onShare, + onUpdateTags, }: { tabIndex: number; onDelete: () => void; onRename: () => void; onDuplicate: () => void; onShare: () => void; + onUpdateTags: () => void; }) => { const [isOpen, setIsOpen] = useState(false); return ( @@ -87,6 +91,7 @@ const Menu = ({ Duplicate Rename Share + Tags Delete @@ -105,6 +110,7 @@ type ProjectCardProps = { project: DashboardProject; hasProPlan: boolean; publisherHost: string; + projectsTags: User["projectsTags"]; }; export const ProjectCard = ({ @@ -116,18 +122,30 @@ export const ProjectCard = ({ createdAt, latestBuildVirtual, previewImageAsset, + tags, }, hasProPlan, publisherHost, + projectsTags, ...props }: ProjectCardProps) => { const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const [isTagsDialogOpen, setIsTagsDialogOpen] = useState(false); const [isHidden, setIsHidden] = useState(false); const handleCloneProject = useCloneProject(id); const [isTransitioning, setIsTransitioning] = useState(false); + // Makes sure there are no project tags that reference deleted User tags. + // We are not deleting project tag from project.tags when deleting User tags. + const projectTagsIds = (tags || []) + .map((tagId) => { + const tag = projectsTags.find((tag) => tag.id === tagId); + return tag ? tag.id : undefined; + }) + .filter(Boolean) as string[]; + useEffect(() => { const linkPath = builderUrl({ origin: window.origin, projectId: id }); @@ -173,7 +191,33 @@ export const ProjectCard = ({ opacity: 0, }} /> - + + {projectsTags.map((tag) => { + const isApplied = projectTagsIds.includes(tag.id); + if (isApplied) { + return ( + {`#${tag.label}`} + ); + } + })} + {previewImageAsset ? ( ) : ( @@ -225,16 +269,11 @@ export const ProjectCard = ({ { - setIsDeleteDialogOpen(true); - }} - onRename={() => { - setIsRenameDialogOpen(true); - }} - onShare={() => { - setIsShareDialogOpen(true); - }} + onDelete={() => setIsDeleteDialogOpen(true)} + onRename={() => setIsRenameDialogOpen(true)} + onShare={() => setIsShareDialogOpen(true)} onDuplicate={handleCloneProject} + onUpdateTags={() => setIsTagsDialogOpen(true)} /> + ); }; diff --git a/apps/builder/app/dashboard/projects/projects.tsx b/apps/builder/app/dashboard/projects/projects.tsx index 2f22cbf5ff35..832566083679 100644 --- a/apps/builder/app/dashboard/projects/projects.tsx +++ b/apps/builder/app/dashboard/projects/projects.tsx @@ -1,4 +1,5 @@ import { + Box, Flex, Grid, List, @@ -11,11 +12,16 @@ import type { DashboardProject } from "@webstudio-is/dashboard"; import { ProjectCard } from "./project-card"; import { CreateProject } from "./project-dialogs"; import { Header, Main } from "../shared/layout"; +import { useSearchParams } from "react-router-dom"; +import { setIsSubsetOf } from "~/shared/shim"; +import type { User } from "~/shared/db/user.server"; +import { Tag } from "./tags"; export const ProjectsGrid = ({ projects, hasProPlan, publisherHost, + projectsTags, }: ProjectsProps) => { return ( @@ -33,6 +39,7 @@ export const ProjectsGrid = ({ project={project} hasProPlan={hasProPlan} publisherHost={publisherHost} + projectsTags={projectsTags} /> ); @@ -46,9 +53,19 @@ type ProjectsProps = { projects: Array; hasProPlan: boolean; publisherHost: string; + projectsTags: User["projectsTags"]; }; export const Projects = (props: ProjectsProps) => { + const [searchParams] = useSearchParams(); + const selectedTags = searchParams.getAll("tag"); + let projects = props.projects; + if (selectedTags.length > 0) { + projects = projects.filter((project) => + setIsSubsetOf(new Set(selectedTags), new Set(project.tags)) + ); + } + return (
@@ -60,12 +77,43 @@ export const Projects = (props: ProjectsProps) => {
- + {props.projectsTags.map((tag, index) => { + return ( + + {tag.label} + + ); + })} + + {projects.length === 0 && ( + + No projects found + + )} + +
); }; diff --git a/apps/builder/app/dashboard/projects/tags.tsx b/apps/builder/app/dashboard/projects/tags.tsx new file mode 100644 index 000000000000..360ce8d362a2 --- /dev/null +++ b/apps/builder/app/dashboard/projects/tags.tsx @@ -0,0 +1,372 @@ +import { useRevalidator, useSearchParams } from "react-router-dom"; +import { useState, type ComponentProps } from "react"; +import { + Text, + theme, + Dialog, + DialogContent, + DialogTitle, + Button, + DialogActions, + DialogClose, + Checkbox, + CheckboxAndLabel, + Label, + InputField, + DialogTitleActions, + Grid, + List, + ListItem, + Flex, + DropdownMenu, + DropdownMenuTrigger, + SmallIconButton, + DropdownMenuContent, + DropdownMenuItem, +} from "@webstudio-is/design-system"; +import { nativeClient } from "~/shared/trpc/trpc-client"; +import type { User } from "~/shared/db/user.server"; +import { nanoid } from "nanoid"; +import { EllipsesIcon, SpinnerIcon } from "@webstudio-is/icons"; +import { colors } from "./colors"; + +type DeleteConfirmationDialogProps = { + onClose: () => void; + onConfirm: () => void; + question: string; +}; + +const DeleteConfirmationDialog = ({ + onClose, + onConfirm, + question, +}: DeleteConfirmationDialogProps) => { + return ( + { + if (isOpen === false) { + onClose(); + } + }} + > + + + {question} + + + + + + + + + + Delete confirmation + + + ); +}; + +const TagsList = ({ + projectId, + projectsTags, + projectTagsIds, + onEdit, +}: { + projectId: string; + projectsTags: User["projectsTags"]; + projectTagsIds: string[]; + onEdit: (tagId: string) => void; +}) => { + const revalidator = useRevalidator(); + const [deleteConfirmationTagId, setDeleteConfirmationTagId] = + useState(); + + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const tagsIds = formData.getAll("tagId") as string[]; + await nativeClient.project.updateTags.mutate({ + projectId, + tags: tagsIds, + }); + revalidator.revalidate(); + }} + > + + + {projectsTags + .sort((a, b) => a.label.localeCompare(b.label)) + .map((tag, index) => ( + {}} index={index} key={tag.id}> + + + + + + + + {/* a11y is completely broken here + focus is not restored to button invoker + @todo fix it eventually and consider restoring from closed value preview dialog + */} + } + /> + + event.preventDefault()} + align="end" + > + { + onEdit(tag.id); + }} + > + Edit + + { + setDeleteConfirmationTagId(tag.id); + }} + > + Delete + + + + + + ))} + {projectsTags.length === 0 && ( + No tags found + )} + {deleteConfirmationTagId && ( + setDeleteConfirmationTagId(undefined)} + onConfirm={async () => { + setDeleteConfirmationTagId(undefined); + const updatedTags = projectsTags.filter( + (tag) => tag.id !== deleteConfirmationTagId + ); + await nativeClient.user.updateProjectsTags.mutate({ + tags: updatedTags, + }); + revalidator.revalidate(); + }} + /> + )} + + +
+ ); +}; + +const TagEdit = ({ + projectsTags, + tag, + onComplete, +}: { + projectsTags: User["projectsTags"]; + tag: User["projectsTags"][number]; + onComplete: () => void; +}) => { + const revalidator = useRevalidator(); + const isExisting = projectsTags.some(({ id }) => id === tag.id); + + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const label = ((formData.get("tag") as string) || "").trim(); + if (tag.label === label || !label) { + return; + } + let updatedTags = []; + if (isExisting) { + updatedTags = projectsTags.map((availableTag) => { + if (availableTag.id === tag.id) { + return { ...availableTag, label }; + } + return availableTag; + }); + } else { + updatedTags = [...projectsTags, { id: tag.id, label }]; + } + + await nativeClient.user.updateProjectsTags.mutate({ + tags: updatedTags, + }); + revalidator.revalidate(); + onComplete(); + }} + > + + + + + + + +
+ ); +}; + +export const TagsDialog = ({ + projectId, + projectsTags, + projectTagsIds, + isOpen, + onOpenChange, +}: { + projectId: string; + projectsTags: User["projectsTags"]; + projectTagsIds: string[]; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}) => { + const [editingTag, setEditingTag] = useState< + User["projectsTags"][number] | undefined + >(); + const revalidator = useRevalidator(); + return ( + + + + {revalidator.state === "loading" && } + + + } + > + Project tags + + {!editingTag && ( + <> + { + setEditingTag(projectsTags.find((tag) => tag.id === tagId)); + }} + /> + + + + + )} + {editingTag && ( + setEditingTag(undefined)} + /> + )} + + + ); +}; + +export const Tag = ({ + index, + tag, + ...props +}: { index: number; tag: User["projectsTags"][number] } & ComponentProps< + typeof Button +>) => { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedTagsIds = searchParams.getAll("tag"); + const color = colors[index] ?? theme.colors.backgroundNeutralDark; + return ( +