Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { Carousel } from './carousel';
import { AnimatePresence, motion } from 'motion/react';

import type { Project } from '@onlook/models';
import { Button } from '@onlook/ui/button';
import { Icons } from '@onlook/ui/icons';

import { Templates } from './templates';
import { TemplateModalPresentation } from './templates/template-modal-presentation';
import { Carousel } from './carousel';
import { HighlightText } from './select/highlight-text';
import { MasonryLayout } from './select/masonry-layout';
import { ProjectCardPresentation } from './select/project-card-presentation';
import { SquareProjectCardPresentation } from './select/square-project-card-presentation';
import { Templates } from './templates';
import { TemplateModalPresentation } from './templates/template-modal-presentation';

interface SelectProjectPresentationProps {
/** All projects including templates */
Expand Down Expand Up @@ -230,11 +230,7 @@ export const SelectProjectPresentation = ({
Create a new project to get started
</div>
<div className="flex justify-center">
<Button
onClick={onCreateBlank}
disabled={isCreatingProject}
variant="default"
>
<Button onClick={onCreateBlank} disabled={isCreatingProject} variant="default">
{isCreatingProject ? (
<Icons.LoadingSpinner className="h-4 w-4 animate-spin" />
) : (
Expand Down Expand Up @@ -308,7 +304,7 @@ export const SelectProjectPresentation = ({
<button
onClick={onCreateBlank}
disabled={isCreatingProject}
className="border-border bg-secondary/40 hover:bg-secondary relative flex aspect-[4/2.8] w-full items-center justify-center rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="border-border bg-secondary/40 hover:bg-secondary relative flex aspect-[4/2.8] w-full items-center justify-center rounded-lg border transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="text-foreground-tertiary flex flex-col items-center justify-center">
{isCreatingProject ? (
Expand Down Expand Up @@ -350,9 +346,16 @@ export const SelectProjectPresentation = ({
searchQuery={debouncedSearchQuery}
HighlightText={HighlightText}
onClick={onProjectClick}
onRename={onRenameProject}
onClone={onCloneProject}
onToggleTemplate={onToggleTemplate}
onDelete={onDeleteProject}
isTemplate={project.metadata.tags.includes(
'template',
)}
/>
</motion.div>
))
)),
]
)}
</AnimatePresence>
Expand Down Expand Up @@ -518,12 +521,16 @@ export const SelectProjectPresentation = ({
}
image={getImageUrl(selectedTemplate)}
isNew={false}
isStarred={selectedTemplate ? starredTemplateIds.has(selectedTemplate.id) : false}
isStarred={
selectedTemplate ? starredTemplateIds.has(selectedTemplate.id) : false
}
onToggleStar={() => selectedTemplate && handleToggleStar(selectedTemplate.id)}
templateProject={selectedTemplate}
onUnmarkTemplate={handleUnmarkTemplate}
onUseTemplate={() => selectedTemplate && onUseTemplate?.(selectedTemplate.id)}
onPreviewTemplate={() => selectedTemplate && onPreviewTemplate?.(selectedTemplate.id)}
onPreviewTemplate={() =>
selectedTemplate && onPreviewTemplate?.(selectedTemplate.id)
}
onEditTemplate={() => selectedTemplate && onEditTemplate?.(selectedTemplate.id)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
'use client';

import { useMemo } from 'react';

import type { Project } from '@onlook/models';
import { Button } from '@onlook/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@onlook/ui/dropdown-menu';
import { Icons } from '@onlook/ui/icons';
import { timeAgo } from '@onlook/utility';
import { useMemo } from 'react';

interface SquareProjectCardPresentationProps {
project: Project;
Expand All @@ -12,6 +21,16 @@ interface SquareProjectCardPresentationProps {
HighlightText?: React.ComponentType<{ text: string; searchQuery: string }>;
/** Callback when card is clicked */
onClick?: (project: Project) => void;
/** Callback when rename is clicked */
onRename?: (project: Project) => void;
/** Callback when clone is clicked */
onClone?: (project: Project) => void;
/** Callback when convert to/from template is clicked */
onToggleTemplate?: (project: Project) => void;
/** Callback when delete is clicked */
onDelete?: (project: Project) => void;
/** Whether this project is a template */
isTemplate?: boolean;
}

/**
Expand All @@ -21,11 +40,19 @@ interface SquareProjectCardPresentationProps {
export function SquareProjectCardPresentation({
project,
imageUrl,
searchQuery = "",
searchQuery = '',
HighlightText,
onClick,
onRename,
onClone,
onToggleTemplate,
onDelete,
isTemplate = false,
}: SquareProjectCardPresentationProps) {
const lastUpdated = useMemo(() => timeAgo(project.metadata.updatedAt), [project.metadata.updatedAt]);
const lastUpdated = useMemo(
() => timeAgo(project.metadata.updatedAt),
[project.metadata.updatedAt],
);

const handleClick = () => {
onClick?.(project);
Expand All @@ -40,49 +67,142 @@ export function SquareProjectCardPresentation({

return (
<div
className="cursor-pointer transition-all duration-300 group"
className="group cursor-pointer transition-all duration-300"
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<div className={`w-full aspect-[4/2.8] rounded-lg overflow-hidden relative shadow-sm transition-all duration-300`}>
<div
className={`relative aspect-[4/2.8] w-full overflow-hidden rounded-lg shadow-sm transition-all duration-300`}
>
{imageUrl ? (
<img src={imageUrl} alt={project.name} className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
<img
src={imageUrl}
alt={project.name}
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
) : (
<>
<div className="absolute inset-0 w-full h-full bg-gradient-to-t from-gray-800/40 via-gray-500/40 to-gray-400/40" />
<div className="absolute inset-0 rounded-lg border-[0.5px] border-gray-500/70" style={{ maskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)' }} />
<div className="absolute inset-0 h-full w-full bg-gradient-to-t from-gray-800/40 via-gray-500/40 to-gray-400/40" />
<div
className="absolute inset-0 rounded-lg border-[0.5px] border-gray-500/70"
style={{
maskImage:
'linear-gradient(to bottom, black 60%, transparent 100%)',
}}
/>
</>
)}

<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />

<div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 to-transparent" />

<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
<div className="absolute top-3 right-3 z-30 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding an aria-label (e.g. aria-label="Project actions") to the dropdown trigger button for better accessibility.

size="default"
variant="ghost"
className="hover:bg-background-onlook flex h-8 w-8 cursor-pointer items-center justify-center p-0 backdrop-blur-lg"
onClick={(e) => e.stopPropagation()}
>
<Icons.DotsHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-50"
align="end"
alignOffset={-4}
sideOffset={8}
onClick={(e) => e.stopPropagation()}
>
{onRename && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onRename(project);
}}
className="text-foreground-active hover:!bg-background-onlook hover:!text-foreground-active gap-2"
>
<Icons.Pencil className="h-4 w-4" />
Rename Project
</DropdownMenuItem>
)}
{onClone && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onClone(project);
}}
className="text-foreground-active hover:!bg-background-onlook hover:!text-foreground-active gap-2"
>
<Icons.Copy className="h-4 w-4" />
Clone Project
</DropdownMenuItem>
)}
{onToggleTemplate && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onToggleTemplate(project);
}}
className="text-foreground-active hover:!bg-background-onlook hover:!text-foreground-active gap-2"
>
{isTemplate ? (
<>
<Icons.CrossL className="h-4 w-4 text-purple-600" />
Unmark as template
</>
) : (
<>
<Icons.FilePlus className="h-4 w-4" />
Convert to template
</>
)}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDelete(project);
}}
className="gap-2 text-red-400 hover:!bg-red-200/80 hover:!text-red-700 dark:text-red-200 dark:hover:!bg-red-800 dark:hover:!text-red-100"
>
<Icons.Trash className="h-4 w-4" />
Delete Project
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

{onClick && (
<div className="absolute inset-0 bg-background/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-30">
<div className="bg-background/30 absolute inset-0 z-30 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
className="gap-2 border border-gray-300 w-auto cursor-pointer bg-white text-black hover:bg-gray-100 px-4 py-2 rounded"
className="w-auto cursor-pointer gap-2 rounded border border-gray-300 bg-white px-4 py-2 text-black hover:bg-gray-100"
>
✏️ Edit
</button>
</div>
)}

<div className="absolute bottom-0 left-0 right-0 p-3 z-10 group-hover:opacity-50 transition-opacity duration-300">
<div className="text-white font-medium text-sm mb-1 truncate drop-shadow-lg">
<div className="absolute right-0 bottom-0 left-0 z-10 p-3 transition-opacity duration-300 group-hover:opacity-50">
<div className="mb-1 truncate text-sm font-medium text-white drop-shadow-lg">
{HighlightText ? (
<HighlightText text={project.name} searchQuery={searchQuery} />
) : (
project.name
)}
</div>
<div className="text-white/70 text-xs mb-1 drop-shadow-lg flex items-center">
<div className="mb-1 flex items-center text-xs text-white/70 drop-shadow-lg">
<span>{lastUpdated} ago</span>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/client/src/stories/ProjectsPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SelectProjectPresentation } from '@/app/projects/_components/select-pre
import type { Project, User } from '@onlook/models';
import { fn } from '@storybook/test';
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';

/**
* ProjectsPageComposed - Full projects page combining TopBar and SelectProject.
Expand Down Expand Up @@ -80,7 +81,7 @@ type Story = StoryObj<typeof meta>;

// Helper to create mock projects
const createMockProject = (overrides?: Partial<Project>): Project => ({
id: crypto.randomUUID(),
id: uuidv4(),
name: 'Project Name',
metadata: {
createdAt: new Date('2024-01-01'),
Expand Down
Loading