[Website] Product hero rework into a single, scroll-driven story. (#21105)
This reworks the /product hero into a single scroll-driven story. It
opens on a collaborative CRM intro — an autoplay cursor ("Alice") tours
real app-preview surfaces (Companies → an Anthropic record, clicking
through its tabs → People → Notes) — then, as you scroll, the light
intro morphs into a dark AI section via one continuous color wipe that
flows up through the (transparent) navigation menu. The AI section
presents four "build" pillars (pipeline board, follow-up tasks, sales
dashboard, email-sequence workflow), each playing an agentic preamble
(thinking + tool steps) and skeleton-loading state before revealing the
finished artifact as a finale. All preview surfaces — table, kanban,
record, dashboard charts, and workflow — are rendered for real and
matched to twenty-front's design (step-8 chart palette, default
opportunity stages, etc.) rather than static images.
Beyond the core morph, this PR layers in the coherence and polish work:
- the intro halftone reuses the AI section's pattern tinted to the brand
blue and scoped to the visual;
- the workflow finale draws its edges/labels in as a staged build;
- AI response copy is kept consistent with the visuals (matching stage
names and task counts);
- the ARR line chart fades in cleanly instead of flashing a partial
draw;
- the experience resets sensibly on scroll — the intro cursor tour
restarts when you scroll back to it, and the AI section returns to its
first tab once you're back at the top.
Here is the video showcasing the experience:
https://github.com/user-attachments/assets/c20708ed-4435-4f1a-a1c6-308b99305a97
Awaiting confirmation about the experience from @Bonapara before
merging.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect width="16" height="16" rx="2" fill="#1C1C1C"/>
|
||||
<g transform="translate(3.4 2.0) scale(0.0114)">
|
||||
<path fill="white" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -2,11 +2,10 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
|
||||
import { HeadingPart, LinkButton } from '@/design-system/components';
|
||||
import { getServerI18n } from '@/lib/i18n/server';
|
||||
import { WebGlMount } from '@/lib/visual-runtime';
|
||||
import { AppPreview } from '@/sections/AppPreview';
|
||||
import { APP_PREVIEW_DATA, AppPreview } from '@/sections/AppPreview';
|
||||
import { TalkToUsButton } from '@/sections/ContactCal';
|
||||
import { HeroBody, HeroCta, HeroHeading, HeroSection } from '@/templates/Hero';
|
||||
import { HomeBackgroundHalftone } from '@/sections/Hero/visuals/components/HomeBackgroundHalftone';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
|
||||
import {
|
||||
Container,
|
||||
Eyebrow,
|
||||
@@ -7,7 +6,7 @@ import {
|
||||
LinkButton,
|
||||
} from '@/design-system/components';
|
||||
import { getServerI18n } from '@/lib/i18n/server';
|
||||
import { AppPreview } from '@/sections/AppPreview';
|
||||
import { APP_PREVIEW_DATA, AppPreview } from '@/sections/AppPreview';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -17,7 +16,9 @@ import NextImage from 'next/image';
|
||||
|
||||
const RootWrapper = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -57,8 +58,9 @@ const StyledSection = styled.section`
|
||||
|
||||
const StyledContainer = styled(Container)`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: center;
|
||||
min-width: 0;
|
||||
padding-bottom: ${theme.spacing(16)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
@@ -83,7 +85,6 @@ const CtasContainer = styled.div`
|
||||
`;
|
||||
|
||||
const PreviewRoot = styled.div`
|
||||
margin-bottom: ${theme.spacing(11)};
|
||||
margin-top: ${theme.spacing(12)};
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
|
||||
import { AI_HERO_TABS } from '@/app/[locale]/product/ai-hero-tabs.data';
|
||||
import { APP_PREVIEW_DATA } from '@/sections/AppPreview';
|
||||
import { Heading, HeadingPart } from '@/design-system/components';
|
||||
import { getServerI18n } from '@/lib/i18n/server';
|
||||
import { TalkToUsButton } from '@/sections/ContactCal';
|
||||
import { HeroVisualScroll } from '@/sections/Hero';
|
||||
|
||||
export function ProductHero() {
|
||||
@@ -13,12 +14,14 @@ export function ProductHero() {
|
||||
return (
|
||||
<HeroVisualScroll
|
||||
aiBody={i18n._(
|
||||
msg`Ask questions, automate tasks, and get insights. All powered by AI that understands your data.`,
|
||||
msg`Ask questions, automate tasks, and get insights with AI that understands your data and helps you move faster every day, end to end across teams.`,
|
||||
)}
|
||||
aiHeading={
|
||||
<Heading size="lg" weight="light">
|
||||
<Trans>
|
||||
<HeadingPart fontFamily="serif">AI that actually</HeadingPart>
|
||||
<HeadingPart fontFamily="serif">
|
||||
...with AI that actually
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="serif">helps you</HeadingPart>
|
||||
<HeadingPart fontFamily="sans">work faster</HeadingPart>
|
||||
@@ -28,7 +31,7 @@ export function ProductHero() {
|
||||
ctaHref="https://app.twenty.com/welcome"
|
||||
ctaLabel={i18n._(msg`Get started`)}
|
||||
introBody={i18n._(
|
||||
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
|
||||
msg`Track relationships, manage pipelines, and take action quickly with an intuitive CRM that helps your team move faster from day one with confidence.`,
|
||||
)}
|
||||
introHeading={
|
||||
<Heading size="lg" weight="light">
|
||||
@@ -40,6 +43,13 @@ export function ProductHero() {
|
||||
</Trans>
|
||||
</Heading>
|
||||
}
|
||||
introSecondaryCta={
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
}
|
||||
tabs={AI_HERO_TABS}
|
||||
visual={APP_PREVIEW_DATA.visual}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import {
|
||||
Heading,
|
||||
HeadingPart,
|
||||
} from '@/design-system/components';
|
||||
import { type InformativeIconKey } from '@/icons';
|
||||
import { type Page, Pages } from '@/lib/pages';
|
||||
import { theme, type Scheme } from '@/theme';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -17,7 +18,7 @@ import { TileContent } from './TileContent';
|
||||
import { TileVisual } from './TileVisual';
|
||||
|
||||
export type FeatureBullet = {
|
||||
icon: string;
|
||||
icon: InformativeIconKey;
|
||||
text: MessageDescriptor;
|
||||
};
|
||||
|
||||
|
||||
+5
-7
@@ -144,13 +144,11 @@ export function TileContent({ counter, spotlight, tile }: TileContentProps) {
|
||||
return (
|
||||
<BulletItem key={bulletIndex}>
|
||||
<BulletIconSlot aria-hidden>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
color="currentColor"
|
||||
size={16}
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
) : null}
|
||||
<Icon
|
||||
color="currentColor"
|
||||
size={16}
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
</BulletIconSlot>
|
||||
<Body
|
||||
as="span"
|
||||
|
||||
+6
-10
@@ -7,7 +7,7 @@ import {
|
||||
HeadingPart,
|
||||
StepperProgressRail,
|
||||
} from '@/design-system/components';
|
||||
import { INFORMATIVE_ICONS } from '@/icons';
|
||||
import { INFORMATIVE_ICONS, type InformativeIconKey } from '@/icons';
|
||||
import { StepperSwipeDeck } from '@/sections/Stepper';
|
||||
import { theme } from '@/theme';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
@@ -18,7 +18,7 @@ import type { ReactNode } from 'react';
|
||||
type ProductStepperContentStepType = {
|
||||
body: MessageDescriptor;
|
||||
heading: ReactNode;
|
||||
icon: string;
|
||||
icon: InformativeIconKey;
|
||||
};
|
||||
|
||||
type ProductStepperLayoutMode = 'scroll' | 'swipe';
|
||||
@@ -151,14 +151,10 @@ function renderProductStepBlock(
|
||||
<>
|
||||
<StepRowHeader>
|
||||
<StepIconBox data-active={String(isActive)}>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
color={
|
||||
isActive ? theme.colors.primary.background[100] : iconColor
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
) : null}
|
||||
<Icon
|
||||
color={isActive ? theme.colors.primary.background[100] : iconColor}
|
||||
size={14}
|
||||
/>
|
||||
</StepIconBox>
|
||||
{step.heading}
|
||||
</StepRowHeader>
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export function Visual({
|
||||
activeStepIndex,
|
||||
stepVisuals,
|
||||
}: ProductStepperVisualProps) {
|
||||
if (!stepVisuals || stepVisuals.length === 0) {
|
||||
if (stepVisuals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import type { InformativeIconKey } from '@/icons';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
@@ -8,6 +9,6 @@ export type StepperVisualProps = {
|
||||
export type ProductStepperStepType = {
|
||||
body: MessageDescriptor;
|
||||
heading: ReactNode;
|
||||
icon: string;
|
||||
icon: InformativeIconKey;
|
||||
visual: ComponentType<StepperVisualProps>;
|
||||
};
|
||||
|
||||
@@ -4,19 +4,19 @@ import type { TabType } from '@/sections/Tabs';
|
||||
|
||||
export const AI_HERO_TABS: TabType[] = [
|
||||
{
|
||||
body: msg`Show me all deals closing this month`,
|
||||
icon: 'search',
|
||||
body: msg`Build a pipeline board grouped by stage`,
|
||||
icon: 'kanban',
|
||||
},
|
||||
{
|
||||
body: msg`Create follow-up tasks for my top 10 accounts`,
|
||||
icon: 'check',
|
||||
body: msg`Generate tasks for my top 10 accounts`,
|
||||
icon: 'checklist',
|
||||
},
|
||||
{
|
||||
body: msg`Summarize this customer's history`,
|
||||
icon: 'edit',
|
||||
body: msg`Build a dashboard of pipeline by stage`,
|
||||
icon: 'chart',
|
||||
},
|
||||
{
|
||||
body: msg`Create a workflow that sends an email sequence`,
|
||||
icon: 'code',
|
||||
body: msg`Draft a workflow for an email sequence`,
|
||||
icon: 'workflow',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,11 +10,11 @@ import { ProductDemo } from '@/app/[locale]/product/_components/ProductDemo';
|
||||
import { ProductFeature } from '@/app/[locale]/product/_components/feature';
|
||||
import { Faq } from '@/sections/Faq';
|
||||
import { ProductHero } from '@/app/[locale]/product/_components/ProductHero';
|
||||
import { Menu, MENU_DATA } from '@/sections/Menu';
|
||||
import { ProductHeroMenuSync } from '@/sections/Hero/components/ProductVisual/product-hero-menu-sync';
|
||||
import { MENU_DATA } from '@/sections/Menu';
|
||||
import { ProductStepperSection } from '@/app/[locale]/product/_components/product-stepper';
|
||||
import { ProductThreeCards } from '@/app/[locale]/product/_components/ProductThreeCards';
|
||||
import { TrustedBy } from '@/sections/TrustedBy';
|
||||
import { theme } from '@/theme';
|
||||
import type { AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('product');
|
||||
@@ -41,12 +41,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
i18n.locale as AppLocale,
|
||||
)}
|
||||
/>
|
||||
<Menu
|
||||
backgroundColor={theme.colors.primary.background[100]}
|
||||
socialLinks={menuSocialLinks}
|
||||
/>
|
||||
|
||||
<ProductHero />
|
||||
<ProductHeroMenuSync socialLinks={menuSocialLinks}>
|
||||
<ProductHero />
|
||||
</ProductHeroMenuSync>
|
||||
|
||||
<TrustedBy />
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ export { NineDotsIcon } from './NineDots';
|
||||
export { W3villaIcon } from './W3Villa';
|
||||
export type { ClientIconProps } from './client-icon-props';
|
||||
|
||||
export const CLIENT_ICONS: Record<string, ComponentType<ClientIconProps>> = {
|
||||
export const CLIENT_ICONS = {
|
||||
'act-education': ActEducationIcon,
|
||||
'alternative-partners': AlternativePartnersIcon,
|
||||
'elevate-consulting': ElevateConsultingIcon,
|
||||
netzero: NetZeroIcon,
|
||||
'nine-dots': NineDotsIcon,
|
||||
w3villa: W3villaIcon,
|
||||
};
|
||||
} satisfies Record<string, ComponentType<ClientIconProps>>;
|
||||
|
||||
export type ClientIconKey = keyof typeof CLIENT_ICONS;
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
NetZeroIcon,
|
||||
NineDotsIcon,
|
||||
W3villaIcon,
|
||||
type ClientIconKey,
|
||||
type ClientIconProps,
|
||||
} from './clients';
|
||||
export { ArrowLeftIcon } from './functional/ArrowLeft';
|
||||
@@ -27,6 +28,7 @@ export {
|
||||
INFORMATIVE_ICONS,
|
||||
SearchIcon,
|
||||
UsersIcon,
|
||||
type InformativeIconKey,
|
||||
type InformativeIconProps,
|
||||
} from './informative';
|
||||
export { Logo } from './logo/Logo';
|
||||
@@ -36,5 +38,6 @@ export {
|
||||
LinkedInIcon,
|
||||
SOCIAL_ICONS,
|
||||
XIcon,
|
||||
type SocialIconKey,
|
||||
type SocialIconProps,
|
||||
} from './social';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IconChartBar } from '@tabler/icons-react';
|
||||
|
||||
type ChartIconProps = { size: number; color: string; strokeWidth?: number };
|
||||
|
||||
export function ChartIcon({ size, color, strokeWidth = 2 }: ChartIconProps) {
|
||||
return <IconChartBar size={size} color={color} stroke={strokeWidth} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IconChecklist } from '@tabler/icons-react';
|
||||
|
||||
type ChecklistIconProps = { size: number; color: string; strokeWidth?: number };
|
||||
|
||||
export function ChecklistIcon({
|
||||
size,
|
||||
color,
|
||||
strokeWidth = 2,
|
||||
}: ChecklistIconProps) {
|
||||
return <IconChecklist size={size} color={color} stroke={strokeWidth} />;
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
|
||||
type EditIconProps = { size: number; color: string; strokeWidth?: number };
|
||||
|
||||
export function EditIcon({ size, color, strokeWidth = 2 }: EditIconProps) {
|
||||
const clipPathId = useId().replace(/:/g, '');
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -16,19 +10,10 @@ export function EditIcon({ size, color, strokeWidth = 2 }: EditIconProps) {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<g
|
||||
clipPath={`url(#${clipPathId})`}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
>
|
||||
<g stroke={color} strokeWidth={strokeWidth}>
|
||||
<path d="M7.604 3.621c-.744.034-1.476.112-2.19.194-.96.11-1.729.88-1.835 1.84-.104.94-.2 1.911-.2 2.904s.096 1.963.2 2.904c.106.96.875 1.729 1.835 1.839.946.109 1.922.211 2.919.211s1.973-.102 2.92-.211a2.094 2.094 0 0 0 1.834-1.839 35 35 0 0 0 .178-2.032" />
|
||||
<path d="M11.916 2.61 8.847 6.09a1.74 1.74 0 0 0-.394.791L8.087 8.6c-.074.349.29.68.63.572l1.724-.542c.287-.09.541-.247.743-.46l3.171-3.336c.578-.608.477-1.625-.222-2.243-.683-.604-1.676-.596-2.217.018Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id={clipPathId}>
|
||||
<path fill="#fff" d="M0 0h16v16H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IconLayoutKanban } from '@tabler/icons-react';
|
||||
|
||||
type KanbanIconProps = { size: number; color: string; strokeWidth?: number };
|
||||
|
||||
export function KanbanIcon({ size, color, strokeWidth = 2 }: KanbanIconProps) {
|
||||
return <IconLayoutKanban size={size} color={color} stroke={strokeWidth} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IconHierarchy3 } from '@tabler/icons-react';
|
||||
|
||||
type WorkflowIconProps = { size: number; color: string; strokeWidth?: number };
|
||||
|
||||
export function WorkflowIcon({
|
||||
size,
|
||||
color,
|
||||
strokeWidth = 2,
|
||||
}: WorkflowIconProps) {
|
||||
return <IconHierarchy3 size={size} color={color} stroke={strokeWidth} />;
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import { type ComponentType } from 'react';
|
||||
import { BookIcon } from './Book';
|
||||
import { ChartIcon } from './Chart';
|
||||
import { ChecklistIcon } from './Checklist';
|
||||
import { CheckIcon } from './Check';
|
||||
import { CodeIcon } from './Code';
|
||||
import { EditIcon } from './Edit';
|
||||
import { EyeIcon } from './Eye';
|
||||
import { KanbanIcon } from './Kanban';
|
||||
import { LightbulbIcon } from './Lightbulb';
|
||||
import { SearchIcon } from './Search';
|
||||
import { TagIcon } from './Tag';
|
||||
import { UsersIcon } from './Users';
|
||||
import { WorkflowIcon } from './Workflow';
|
||||
|
||||
export { BookIcon } from './Book';
|
||||
export { ChartIcon } from './Chart';
|
||||
export { ChecklistIcon } from './Checklist';
|
||||
export { CheckIcon } from './Check';
|
||||
export { CodeIcon } from './Code';
|
||||
export { EditIcon } from './Edit';
|
||||
export { EyeIcon } from './Eye';
|
||||
export { KanbanIcon } from './Kanban';
|
||||
export { LightbulbIcon } from './Lightbulb';
|
||||
export { SearchIcon } from './Search';
|
||||
export { TagIcon } from './Tag';
|
||||
export { UsersIcon } from './Users';
|
||||
export { WorkflowIcon } from './Workflow';
|
||||
|
||||
export type InformativeIconProps = {
|
||||
size: number;
|
||||
@@ -25,17 +33,20 @@ export type InformativeIconProps = {
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
export const INFORMATIVE_ICONS: Record<
|
||||
string,
|
||||
ComponentType<InformativeIconProps>
|
||||
> = {
|
||||
export const INFORMATIVE_ICONS = {
|
||||
book: BookIcon,
|
||||
chart: ChartIcon,
|
||||
check: CheckIcon,
|
||||
checklist: ChecklistIcon,
|
||||
code: CodeIcon,
|
||||
edit: EditIcon,
|
||||
eye: EyeIcon,
|
||||
kanban: KanbanIcon,
|
||||
lightbulb: LightbulbIcon,
|
||||
search: SearchIcon,
|
||||
tag: TagIcon,
|
||||
users: UsersIcon,
|
||||
};
|
||||
workflow: WorkflowIcon,
|
||||
} satisfies Record<string, ComponentType<InformativeIconProps>>;
|
||||
|
||||
export type InformativeIconKey = keyof typeof INFORMATIVE_ICONS;
|
||||
|
||||
@@ -11,9 +11,11 @@ export { XIcon } from './X';
|
||||
|
||||
export type SocialIconProps = { size: number; fillColor: string };
|
||||
|
||||
export const SOCIAL_ICONS: Record<string, ComponentType<SocialIconProps>> = {
|
||||
export const SOCIAL_ICONS = {
|
||||
discord: DiscordIcon,
|
||||
github: GitHubIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
x: XIcon,
|
||||
};
|
||||
} satisfies Record<string, ComponentType<SocialIconProps>>;
|
||||
|
||||
export type SocialIconKey = keyof typeof SOCIAL_ICONS;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ClientIconKey } from '@/icons';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -35,7 +36,7 @@ export type CaseStudyData = {
|
||||
title: ReactNode;
|
||||
author: string;
|
||||
authorAvatarSrc?: string;
|
||||
clientIcon: string;
|
||||
clientIcon: ClientIconKey;
|
||||
heroImageSrc: string;
|
||||
industry?: MessageDescriptor;
|
||||
authorRole?: MessageDescriptor;
|
||||
@@ -62,7 +63,7 @@ export type CaseStudyCatalogEntry = {
|
||||
title: MessageDescriptor;
|
||||
author: string;
|
||||
authorAvatarSrc?: string;
|
||||
clientIcon: string;
|
||||
clientIcon: ClientIconKey;
|
||||
heroImageSrc: string;
|
||||
};
|
||||
catalogCard: CaseStudyData['catalogCard'];
|
||||
|
||||
@@ -4,16 +4,16 @@ import { scheduleVisualMount } from '@/lib/visual-runtime';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect } from 'react';
|
||||
import type { AppPreviewConfig } from './types';
|
||||
import { AppWindow } from './AppWindow/AppWindow';
|
||||
import { Terminal } from './Terminal/Terminal';
|
||||
import { COLORS } from './Shared/utils/app-preview-theme';
|
||||
import { AppPreviewNavbar } from './Shell/AppPreviewNavbar';
|
||||
import { AppPreviewSidebar } from './Shell/AppPreviewSidebar';
|
||||
import { AppPreviewViewbar } from './Shell/AppPreviewViewbar';
|
||||
import { renderPageDefinition } from './Shell/PageRenderers';
|
||||
import {
|
||||
AppPreviewFrame,
|
||||
type AppPreviewFrameMode,
|
||||
} from './AppWindow/AppPreviewFrame';
|
||||
import { AppPreviewLayout } from './Shell/AppPreviewLayout';
|
||||
import { type DesktopSidebarMode } from './Shell/AppPreviewSidebar';
|
||||
import { preloadDeferredPages } from './Shell/preload-deferred-pages';
|
||||
import { useAppPreviewState } from './Shell/use-app-preview-state';
|
||||
import { useAppPreviewExperience } from './Shell/use-app-preview-experience';
|
||||
import { Terminal } from './Terminal/Terminal';
|
||||
import type { AppPreviewConfig } from './types';
|
||||
import { WindowOrderProvider } from './WindowOrder/WindowOrderProvider';
|
||||
|
||||
const StyledAppPreview = styled.div`
|
||||
@@ -28,83 +28,47 @@ const StyledAppPreview = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const WINDOW_WIDTH = 1040;
|
||||
const WINDOW_HEIGHT = 676;
|
||||
|
||||
const ShellScene = styled.div`
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto;
|
||||
max-height: 740px;
|
||||
height: ${WINDOW_HEIGHT}px;
|
||||
margin: 0 auto ${theme.spacing(16)};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
aspect-ratio: 1280 / 832;
|
||||
}
|
||||
`;
|
||||
|
||||
const AppLayout = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const RightPane = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 12px 8px 12px 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
padding-right: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const IndexSurface = styled.div`
|
||||
background: ${COLORS.background};
|
||||
border: 1px solid ${COLORS.border};
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
width: ${WINDOW_WIDTH}px;
|
||||
`;
|
||||
|
||||
type AppPreviewProps = {
|
||||
desktopSidebarMode?: DesktopSidebarMode;
|
||||
frameMode?: AppPreviewFrameMode;
|
||||
showTerminal?: boolean;
|
||||
visual: AppPreviewConfig;
|
||||
};
|
||||
|
||||
export function AppPreview({ showTerminal = true, visual }: AppPreviewProps) {
|
||||
export function AppPreview({
|
||||
desktopSidebarMode = 'expanded',
|
||||
frameMode,
|
||||
showTerminal = true,
|
||||
visual,
|
||||
}: AppPreviewProps) {
|
||||
const {
|
||||
activeItem,
|
||||
activeLabel,
|
||||
activeItemId,
|
||||
activeItemLabel,
|
||||
activePage,
|
||||
favorites,
|
||||
handleChatReset,
|
||||
handleJumpToConversationEnd,
|
||||
handleObjectCreated,
|
||||
handleSelectLabel,
|
||||
handleToggleFolder,
|
||||
selectPageItem,
|
||||
toggleFolder,
|
||||
highlightedItemId,
|
||||
openFolderIds,
|
||||
revealedObjectIds,
|
||||
workspaceNav,
|
||||
} = useAppPreviewState(visual);
|
||||
const activeHeader = activePage?.header;
|
||||
const showViewBar =
|
||||
activePage !== null &&
|
||||
activePage !== undefined &&
|
||||
activePage.type !== 'dashboard' &&
|
||||
activePage.type !== 'record' &&
|
||||
activePage.type !== 'workflow';
|
||||
workspaceEntries,
|
||||
} = useAppPreviewExperience(visual);
|
||||
const resolvedFrameMode = frameMode ?? (showTerminal ? 'windowed' : 'static');
|
||||
const needsWindowOrder = showTerminal || resolvedFrameMode === 'windowed';
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -117,61 +81,46 @@ export function AppPreview({ showTerminal = true, visual }: AppPreviewProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const previewFrame = (
|
||||
<AppPreviewFrame mode={resolvedFrameMode}>
|
||||
<AppPreviewLayout
|
||||
activeItem={activeItem}
|
||||
activeItemId={activeItemId}
|
||||
activeItemLabel={activeItemLabel}
|
||||
desktopSidebarMode={desktopSidebarMode}
|
||||
favorites={favorites}
|
||||
highlightedItemId={highlightedItemId ?? undefined}
|
||||
onSelectPageItem={selectPageItem}
|
||||
onToggleFolder={toggleFolder}
|
||||
openFolderIds={openFolderIds}
|
||||
page={activePage}
|
||||
revealedObjectIds={revealedObjectIds}
|
||||
workspaceEntries={workspaceEntries}
|
||||
/>
|
||||
</AppPreviewFrame>
|
||||
);
|
||||
|
||||
const previewContent = (
|
||||
<>
|
||||
{previewFrame}
|
||||
{showTerminal ? (
|
||||
<Terminal
|
||||
onObjectCreated={handleObjectCreated}
|
||||
onChatReset={handleChatReset}
|
||||
onJumpToConversationEnd={handleJumpToConversationEnd}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAppPreview>
|
||||
<ShellScene>
|
||||
<WindowOrderProvider>
|
||||
<AppWindow>
|
||||
<AppLayout>
|
||||
<AppPreviewSidebar
|
||||
favoritesNav={visual.favoritesNav}
|
||||
highlightedItemId={highlightedItemId ?? undefined}
|
||||
onSelectLabel={handleSelectLabel}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
openFolderIds={openFolderIds}
|
||||
selectedLabel={activeLabel}
|
||||
workspaceName={visual.workspace.name}
|
||||
workspaceNav={workspaceNav}
|
||||
/>
|
||||
|
||||
<RightPane>
|
||||
<AppPreviewNavbar
|
||||
activeItem={activeItem}
|
||||
activeLabel={activeLabel}
|
||||
navbarActions={activeHeader?.navbarActions}
|
||||
revealedObjectIds={revealedObjectIds}
|
||||
/>
|
||||
|
||||
<IndexSurface>
|
||||
{showViewBar ? (
|
||||
<AppPreviewViewbar
|
||||
actions={activeHeader?.actions ?? []}
|
||||
count={activeHeader?.count}
|
||||
pageType={activePage.type}
|
||||
showListIcon={activeHeader?.showListIcon ?? false}
|
||||
title={activeHeader?.title ?? activeLabel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activePage
|
||||
? renderPageDefinition(
|
||||
activePage,
|
||||
handleSelectLabel,
|
||||
activeItem?.id ?? activeLabel,
|
||||
)
|
||||
: null}
|
||||
</IndexSurface>
|
||||
</RightPane>
|
||||
</AppLayout>
|
||||
</AppWindow>
|
||||
{showTerminal ? (
|
||||
<Terminal
|
||||
onObjectCreated={handleObjectCreated}
|
||||
onChatReset={handleChatReset}
|
||||
onJumpToConversationEnd={handleJumpToConversationEnd}
|
||||
/>
|
||||
) : null}
|
||||
</WindowOrderProvider>
|
||||
{needsWindowOrder ? (
|
||||
<WindowOrderProvider>{previewContent}</WindowOrderProvider>
|
||||
) : (
|
||||
previewContent
|
||||
)}
|
||||
</ShellScene>
|
||||
</StyledAppPreview>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
import { VISUAL_TOKENS } from '../Shared/utils/app-preview-tokens';
|
||||
import { WINDOW_SHADOWS } from '../Shared/utils/window-shadows';
|
||||
import { AppWindow } from './AppWindow';
|
||||
import { MacWindowBar } from './MacWindowBar';
|
||||
|
||||
export type AppPreviewFrameMode = 'static' | 'windowed';
|
||||
|
||||
const APP_PREVIEW_FRAME_MAX_WIDTH = 1040;
|
||||
const APP_PREVIEW_FRAME_ASPECT_RATIO = '1280 / 832';
|
||||
|
||||
const StaticFrameRoot = styled.div`
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
// compact fills the (fixed-height, fluid-width) scene box without aspect-ratio,
|
||||
// so the board reflows instead of the whole window scaling with width.
|
||||
// floatingShadow swaps the resting halo for a downward-biased shadow (product
|
||||
// hero only), so the soft shadow drops below instead of haloing above the title.
|
||||
const StaticShell = styled.div<{ $compact: boolean; $floatingShadow: boolean }>`
|
||||
aspect-ratio: ${({ $compact }) =>
|
||||
$compact ? 'auto' : APP_PREVIEW_FRAME_ASPECT_RATIO};
|
||||
background-color: ${VISUAL_TOKENS.background.primary};
|
||||
background-image: ${VISUAL_TOKENS.background.noisy};
|
||||
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
|
||||
border-radius: 20px;
|
||||
box-shadow: ${({ $floatingShadow }) =>
|
||||
$floatingShadow
|
||||
? WINDOW_SHADOWS.mobileFloating
|
||||
: WINDOW_SHADOWS.mobileResting};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: ${({ $compact }) => ($compact ? '100%' : 'auto')};
|
||||
max-height: ${({ $compact }) => ($compact ? 'none' : '100%')};
|
||||
max-width: ${({ $compact }) =>
|
||||
$compact ? 'none' : `${APP_PREVIEW_FRAME_MAX_WIDTH}px`};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
box-shadow: ${({ $floatingShadow }) =>
|
||||
$floatingShadow ? WINDOW_SHADOWS.floating : WINDOW_SHADOWS.resting};
|
||||
}
|
||||
`;
|
||||
|
||||
const StaticContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type AppPreviewFrameProps = {
|
||||
children: ReactNode;
|
||||
compact?: boolean;
|
||||
floatingShadow?: boolean;
|
||||
mode: AppPreviewFrameMode;
|
||||
};
|
||||
|
||||
export function AppPreviewFrame({
|
||||
children,
|
||||
compact = false,
|
||||
floatingShadow = false,
|
||||
mode,
|
||||
}: AppPreviewFrameProps) {
|
||||
if (mode === 'windowed') {
|
||||
return <AppWindow>{children}</AppWindow>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticFrameRoot>
|
||||
<StaticShell $compact={compact} $floatingShadow={floatingShadow}>
|
||||
<MacWindowBar interactive={false} />
|
||||
<StaticContent>{children}</StaticContent>
|
||||
</StaticShell>
|
||||
</StaticFrameRoot>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,6 @@ const MIN_WIDTH = 640;
|
||||
const MIN_HEIGHT = 420;
|
||||
const MIN_EDGE_GAP = 0;
|
||||
const INITIAL_MAX_WIDTH = 1040;
|
||||
const INITIAL_ASPECT_RATIO = 1280 / 832;
|
||||
const MOBILE_PARENT_BREAKPOINT = 640;
|
||||
|
||||
const Shell = styled.div<{
|
||||
$isResizing: boolean;
|
||||
@@ -139,22 +137,8 @@ export const AppWindow = ({ children }: AppWindowProps) => {
|
||||
}
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
|
||||
if (parentRect.width < MOBILE_PARENT_BREAKPOINT) {
|
||||
const mobileWidth = parentRect.width;
|
||||
const mobileHeight = Math.min(
|
||||
parentRect.height,
|
||||
mobileWidth / INITIAL_ASPECT_RATIO,
|
||||
);
|
||||
setSize({ width: mobileWidth, height: mobileHeight });
|
||||
setPosition({ left: 0, top: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const newWidth = Math.min(parentRect.width, INITIAL_MAX_WIDTH);
|
||||
const newHeight = Math.min(
|
||||
parentRect.height,
|
||||
newWidth / INITIAL_ASPECT_RATIO,
|
||||
);
|
||||
const newWidth = INITIAL_MAX_WIDTH;
|
||||
const newHeight = parentRect.height;
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { TerminalTrafficLights } from '../Terminal/TerminalTrafficLights/TerminalTrafficLights';
|
||||
|
||||
type MacWindowBarProps = {
|
||||
interactive?: boolean;
|
||||
isDragging?: boolean;
|
||||
onDragStart?: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
title?: string;
|
||||
onDragStart: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
const BAR_BACKGROUND = '#F7F7F7';
|
||||
@@ -17,12 +18,18 @@ const BAR_VERTICAL_PADDING = 8;
|
||||
const BAR_HORIZONTAL_PADDING = 12;
|
||||
const TRAFFIC_LIGHT_WIDTH = 52;
|
||||
|
||||
const BarRoot = styled.div<{ $isDragging: boolean }>`
|
||||
const BarRoot = styled.div<{ $interactive: boolean; $isDragging: boolean }>`
|
||||
align-items: center;
|
||||
background: ${BAR_BACKGROUND};
|
||||
border-bottom: 1px solid ${BAR_BORDER};
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
cursor: ${({ $isDragging }) => ($isDragging ? 'grabbing' : 'grab')};
|
||||
cursor: ${({ $interactive, $isDragging }) => {
|
||||
if (!$interactive) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return $isDragging ? 'grabbing' : 'grab';
|
||||
}};
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
@@ -47,12 +54,17 @@ const RightSpacer = styled.div`
|
||||
`;
|
||||
|
||||
export const MacWindowBar = ({
|
||||
title = 'Twenty',
|
||||
interactive = true,
|
||||
isDragging = false,
|
||||
onDragStart,
|
||||
isDragging,
|
||||
title = 'Twenty',
|
||||
}: MacWindowBarProps) => {
|
||||
return (
|
||||
<BarRoot $isDragging={isDragging} onPointerDown={onDragStart}>
|
||||
<BarRoot
|
||||
$interactive={interactive}
|
||||
$isDragging={isDragging}
|
||||
onPointerDown={interactive ? onDragStart : undefined}
|
||||
>
|
||||
<TerminalTrafficLights horizontalInset={0} />
|
||||
<Title>{title}</Title>
|
||||
<RightSpacer aria-hidden />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
KanbanPageDefinition,
|
||||
PageDefinition,
|
||||
SidebarItemDef,
|
||||
SidebarPageItemDef,
|
||||
TablePageDefinition,
|
||||
WorkflowPageDefinition,
|
||||
} from '../types';
|
||||
@@ -80,12 +80,8 @@ function normalizeWorkflowPage(
|
||||
}
|
||||
|
||||
export function normalizePage(
|
||||
item: SidebarItemDef,
|
||||
item: SidebarPageItemDef,
|
||||
defaults: PageDefaults,
|
||||
): PageDefinition | null {
|
||||
if (!item.page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
): PageDefinition {
|
||||
return normalizeExplicitPage(item.page, defaults);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const ROCKET_PAGE: TablePageDefinition = {
|
||||
},
|
||||
serialNumber: { type: 'text', value: 'B1062' },
|
||||
manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' },
|
||||
status: { type: 'tag', value: 'Active' },
|
||||
status: { type: 'select', color: 'green', value: 'Active' },
|
||||
reusable: { type: 'boolean', value: true },
|
||||
launchDate: { type: 'text', value: 'Apr 11, 2024' },
|
||||
targetOrbit: { type: 'text', value: 'LEO' },
|
||||
@@ -51,7 +51,7 @@ const ROCKET_PAGE: TablePageDefinition = {
|
||||
},
|
||||
serialNumber: { type: 'text', value: 'S29' },
|
||||
manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' },
|
||||
status: { type: 'tag', value: 'Testing' },
|
||||
status: { type: 'select', color: 'orange', value: 'Testing' },
|
||||
reusable: { type: 'boolean', value: true },
|
||||
launchDate: { type: 'text', value: 'Jun 6, 2024' },
|
||||
targetOrbit: { type: 'text', value: 'Mars Transfer' },
|
||||
@@ -74,7 +74,7 @@ const ROCKET_PAGE: TablePageDefinition = {
|
||||
name: 'Blue Origin',
|
||||
domain: 'blueorigin.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'Active' },
|
||||
status: { type: 'select', color: 'green', value: 'Active' },
|
||||
reusable: { type: 'boolean', value: true },
|
||||
launchDate: { type: 'text', value: 'Jan 16, 2025' },
|
||||
targetOrbit: { type: 'text', value: 'GTO' },
|
||||
@@ -97,7 +97,7 @@ const ROCKET_PAGE: TablePageDefinition = {
|
||||
name: 'Rocket Lab',
|
||||
domain: 'rocketlabusa.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'Active' },
|
||||
status: { type: 'select', color: 'green', value: 'Active' },
|
||||
reusable: { type: 'boolean', value: false },
|
||||
launchDate: { type: 'text', value: 'Sep 20, 2024' },
|
||||
targetOrbit: { type: 'text', value: 'SSO' },
|
||||
@@ -120,7 +120,7 @@ const ROCKET_PAGE: TablePageDefinition = {
|
||||
name: 'Arianespace',
|
||||
domain: 'arianespace.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'Active' },
|
||||
status: { type: 'select', color: 'green', value: 'Active' },
|
||||
reusable: { type: 'boolean', value: false },
|
||||
launchDate: { type: 'text', value: 'Jul 9, 2024' },
|
||||
targetOrbit: { type: 'text', value: 'GTO' },
|
||||
@@ -143,7 +143,7 @@ const ROCKET_PAGE: TablePageDefinition = {
|
||||
name: 'ULA',
|
||||
domain: 'ulalaunch.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'Active' },
|
||||
status: { type: 'select', color: 'green', value: 'Active' },
|
||||
reusable: { type: 'boolean', value: false },
|
||||
launchDate: { type: 'text', value: 'Oct 4, 2024' },
|
||||
targetOrbit: { type: 'text', value: 'GEO' },
|
||||
@@ -180,8 +180,8 @@ const LAUNCH_PAGE: TablePageDefinition = {
|
||||
cells: {
|
||||
name: { type: 'text', value: 'CRS-29', shortLabel: 'C', tone: 'blue' },
|
||||
missionCode: { type: 'text', value: 'NASA-CRS-29' },
|
||||
status: { type: 'tag', value: 'Success' },
|
||||
missionType: { type: 'tag', value: 'Cargo' },
|
||||
status: { type: 'select', color: 'green', value: 'Success' },
|
||||
missionType: { type: 'select', color: 'orange', value: 'Cargo' },
|
||||
plannedLaunchAt: { type: 'text', value: 'Nov 9, 2023' },
|
||||
rocket: {
|
||||
type: 'relation',
|
||||
@@ -204,8 +204,8 @@ const LAUNCH_PAGE: TablePageDefinition = {
|
||||
tone: 'amber',
|
||||
},
|
||||
missionCode: { type: 'text', value: 'NASA-ART-2' },
|
||||
status: { type: 'tag', value: 'Scheduled' },
|
||||
missionType: { type: 'tag', value: 'Crewed' },
|
||||
status: { type: 'select', color: 'blue', value: 'Scheduled' },
|
||||
missionType: { type: 'select', color: 'purple', value: 'Crewed' },
|
||||
plannedLaunchAt: { type: 'text', value: 'Sep 26, 2025' },
|
||||
rocket: {
|
||||
type: 'relation',
|
||||
@@ -228,8 +228,8 @@ const LAUNCH_PAGE: TablePageDefinition = {
|
||||
tone: 'amber',
|
||||
},
|
||||
missionCode: { type: 'text', value: 'SPX-IFT-5' },
|
||||
status: { type: 'tag', value: 'Success' },
|
||||
missionType: { type: 'tag', value: 'Test' },
|
||||
status: { type: 'select', color: 'green', value: 'Success' },
|
||||
missionType: { type: 'select', color: 'purple', value: 'Test' },
|
||||
plannedLaunchAt: { type: 'text', value: 'Oct 13, 2024' },
|
||||
rocket: {
|
||||
type: 'relation',
|
||||
@@ -247,8 +247,8 @@ const LAUNCH_PAGE: TablePageDefinition = {
|
||||
cells: {
|
||||
name: { type: 'text', value: 'Euclid', shortLabel: 'E', tone: 'teal' },
|
||||
missionCode: { type: 'text', value: 'ESA-EUC-1' },
|
||||
status: { type: 'tag', value: 'Success' },
|
||||
missionType: { type: 'tag', value: 'Commercial' },
|
||||
status: { type: 'select', color: 'green', value: 'Success' },
|
||||
missionType: { type: 'select', color: 'blue', value: 'Commercial' },
|
||||
plannedLaunchAt: { type: 'text', value: 'Jul 1, 2023' },
|
||||
rocket: {
|
||||
type: 'relation',
|
||||
@@ -271,8 +271,8 @@ const LAUNCH_PAGE: TablePageDefinition = {
|
||||
tone: 'purple',
|
||||
},
|
||||
missionCode: { type: 'text', value: 'NASA-PSY-1' },
|
||||
status: { type: 'tag', value: 'Success' },
|
||||
missionType: { type: 'tag', value: 'Commercial' },
|
||||
status: { type: 'select', color: 'green', value: 'Success' },
|
||||
missionType: { type: 'select', color: 'blue', value: 'Commercial' },
|
||||
plannedLaunchAt: { type: 'text', value: 'Oct 13, 2023' },
|
||||
rocket: {
|
||||
type: 'relation',
|
||||
@@ -310,8 +310,8 @@ const PAYLOAD_PAGE: TablePageDefinition = {
|
||||
shortLabel: 'S',
|
||||
tone: 'blue',
|
||||
},
|
||||
payloadType: { type: 'tag', value: 'Satellite' },
|
||||
status: { type: 'tag', value: 'Deployed' },
|
||||
payloadType: { type: 'select', color: 'blue', value: 'Satellite' },
|
||||
status: { type: 'select', color: 'green', value: 'Deployed' },
|
||||
customer: {
|
||||
type: 'relation',
|
||||
items: [{ name: 'Starlink', shortLabel: 'S', tone: 'blue' }],
|
||||
@@ -333,8 +333,8 @@ const PAYLOAD_PAGE: TablePageDefinition = {
|
||||
shortLabel: 'O',
|
||||
tone: 'amber',
|
||||
},
|
||||
payloadType: { type: 'tag', value: 'Crew Capsule' },
|
||||
status: { type: 'tag', value: 'Integrated' },
|
||||
payloadType: { type: 'select', color: 'purple', value: 'Crew Capsule' },
|
||||
status: { type: 'select', color: 'blue', value: 'Integrated' },
|
||||
customer: {
|
||||
type: 'relation',
|
||||
items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
|
||||
@@ -356,8 +356,8 @@ const PAYLOAD_PAGE: TablePageDefinition = {
|
||||
shortLabel: 'D',
|
||||
tone: 'blue',
|
||||
},
|
||||
payloadType: { type: 'tag', value: 'Cargo' },
|
||||
status: { type: 'tag', value: 'Launched' },
|
||||
payloadType: { type: 'select', color: 'orange', value: 'Cargo' },
|
||||
status: { type: 'select', color: 'green', value: 'Launched' },
|
||||
customer: {
|
||||
type: 'relation',
|
||||
items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
|
||||
@@ -379,8 +379,8 @@ const PAYLOAD_PAGE: TablePageDefinition = {
|
||||
shortLabel: 'P',
|
||||
tone: 'purple',
|
||||
},
|
||||
payloadType: { type: 'tag', value: 'Probe' },
|
||||
status: { type: 'tag', value: 'Deployed' },
|
||||
payloadType: { type: 'select', color: 'teal', value: 'Probe' },
|
||||
status: { type: 'select', color: 'green', value: 'Deployed' },
|
||||
customer: {
|
||||
type: 'relation',
|
||||
items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
|
||||
@@ -402,8 +402,8 @@ const PAYLOAD_PAGE: TablePageDefinition = {
|
||||
shortLabel: 'E',
|
||||
tone: 'teal',
|
||||
},
|
||||
payloadType: { type: 'tag', value: 'Satellite' },
|
||||
status: { type: 'tag', value: 'Deployed' },
|
||||
payloadType: { type: 'select', color: 'blue', value: 'Satellite' },
|
||||
status: { type: 'select', color: 'green', value: 'Deployed' },
|
||||
customer: {
|
||||
type: 'relation',
|
||||
items: [{ name: 'ESA', shortLabel: 'E', tone: 'teal' }],
|
||||
@@ -442,7 +442,7 @@ const LAUNCH_SITE_PAGE: TablePageDefinition = {
|
||||
siteCode: { type: 'text', value: 'KSC-39A' },
|
||||
padName: { type: 'text', value: 'Launch Complex 39A' },
|
||||
country: { type: 'text', value: 'United States' },
|
||||
siteStatus: { type: 'tag', value: 'Active' },
|
||||
siteStatus: { type: 'select', color: 'green', value: 'Active' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -457,7 +457,7 @@ const LAUNCH_SITE_PAGE: TablePageDefinition = {
|
||||
siteCode: { type: 'text', value: 'CCSFS-40' },
|
||||
padName: { type: 'text', value: 'Space Launch Complex 40' },
|
||||
country: { type: 'text', value: 'United States' },
|
||||
siteStatus: { type: 'tag', value: 'Active' },
|
||||
siteStatus: { type: 'select', color: 'green', value: 'Active' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -472,7 +472,7 @@ const LAUNCH_SITE_PAGE: TablePageDefinition = {
|
||||
siteCode: { type: 'text', value: 'SB-01' },
|
||||
padName: { type: 'text', value: 'Orbital Launch Pad A' },
|
||||
country: { type: 'text', value: 'United States' },
|
||||
siteStatus: { type: 'tag', value: 'Active' },
|
||||
siteStatus: { type: 'select', color: 'green', value: 'Active' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -487,7 +487,7 @@ const LAUNCH_SITE_PAGE: TablePageDefinition = {
|
||||
siteCode: { type: 'text', value: 'VSFB-4E' },
|
||||
padName: { type: 'text', value: 'Space Launch Complex 4E' },
|
||||
country: { type: 'text', value: 'United States' },
|
||||
siteStatus: { type: 'tag', value: 'Active' },
|
||||
siteStatus: { type: 'select', color: 'green', value: 'Active' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -502,7 +502,7 @@ const LAUNCH_SITE_PAGE: TablePageDefinition = {
|
||||
siteCode: { type: 'text', value: 'CSG-4' },
|
||||
padName: { type: 'text', value: 'Ensemble de Lancement 4' },
|
||||
country: { type: 'text', value: 'French Guiana' },
|
||||
siteStatus: { type: 'tag', value: 'Active' },
|
||||
siteStatus: { type: 'select', color: 'green', value: 'Active' },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useState, type CSSProperties } from 'react';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { createAnimationFrameLoop } from '@/lib/animation';
|
||||
|
||||
import type {
|
||||
DashboardBarChart,
|
||||
DashboardDonutChart,
|
||||
DashboardLineChart,
|
||||
} from '../../types';
|
||||
import { APP_FONT, COLORS } from '../../Shared/utils/app-preview-theme';
|
||||
|
||||
const ACCENT = '#8da4ef';
|
||||
|
||||
const ChartFrame = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AxisLabel = styled.span`
|
||||
color: ${COLORS.textSecondary};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const PlotArea = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Gridlines = styled.div<{ $bottom: number }>`
|
||||
bottom: ${({ $bottom }) => `${$bottom}px`};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const GridLine = styled.span`
|
||||
border-top: 1px dashed ${COLORS.borderLight};
|
||||
display: block;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const GRID_LINES = [0, 1, 2, 3];
|
||||
|
||||
// --- Line chart -------------------------------------------------------------
|
||||
|
||||
const LINE_W = 320;
|
||||
const LINE_H = 120;
|
||||
const LINE_PAD = 10;
|
||||
|
||||
const LineSvg = styled.svg`
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
||||
.line-stroke {
|
||||
transition: opacity 420ms ease;
|
||||
}
|
||||
|
||||
.line-area {
|
||||
transition: opacity 500ms ease 160ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.line-area,
|
||||
.line-stroke {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DashboardLineChart({ data }: { data: DashboardLineChart }) {
|
||||
const [drawn, setDrawn] = useState(false);
|
||||
const gradientId = `dashboard-line-fill-${useId().replace(/:/g, '')}`;
|
||||
|
||||
useEffect(() => {
|
||||
const task = createAnimationFrameLoop({
|
||||
onFrame: () => {
|
||||
setDrawn(true);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
task.start();
|
||||
|
||||
return () => task.stop();
|
||||
}, []);
|
||||
|
||||
const max = Math.max(...data.values);
|
||||
const min = Math.min(...data.values);
|
||||
const range = max - min || 1;
|
||||
const stepX = (LINE_W - LINE_PAD * 2) / (data.values.length - 1);
|
||||
|
||||
const points = data.values.map((value, index) => {
|
||||
const x = LINE_PAD + index * stepX;
|
||||
const y = LINE_PAD + (LINE_H - LINE_PAD * 2) * (1 - (value - min) / range);
|
||||
|
||||
return [x, y] as const;
|
||||
});
|
||||
|
||||
const linePath = points
|
||||
.map(
|
||||
([x, y], index) =>
|
||||
`${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`,
|
||||
)
|
||||
.join(' ');
|
||||
const lastX = points[points.length - 1][0];
|
||||
const firstX = points[0][0];
|
||||
const areaPath = `${linePath} L${lastX.toFixed(1)} ${LINE_H - LINE_PAD} L${firstX.toFixed(1)} ${LINE_H - LINE_PAD} Z`;
|
||||
|
||||
return (
|
||||
<ChartFrame>
|
||||
<PlotArea>
|
||||
<Gridlines $bottom={0} aria-hidden="true">
|
||||
{GRID_LINES.map((line) => (
|
||||
<GridLine key={line} />
|
||||
))}
|
||||
</Gridlines>
|
||||
<LineSvg
|
||||
viewBox={`0 0 ${LINE_W} ${LINE_H}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={ACCENT} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={ACCENT} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
className="line-area"
|
||||
d={areaPath}
|
||||
fill={`url(#${gradientId})`}
|
||||
style={{ opacity: drawn ? 1 : 0 }}
|
||||
/>
|
||||
<path
|
||||
className="line-stroke"
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={ACCENT}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ opacity: drawn ? 1 : 0 }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</LineSvg>
|
||||
</PlotArea>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bar chart --------------------------------------------------------------
|
||||
|
||||
const BarColumns = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding-top: 8px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const BarColumn = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const BarTrack = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Bar = styled.div<{ $heightPct: number; $index: number }>`
|
||||
animation: barGrow 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: ${({ $index }) => `${$index * 90}ms`};
|
||||
background: ${ACCENT};
|
||||
border-radius: 4px 4px 0 0;
|
||||
height: ${({ $heightPct }) => `${$heightPct}%`};
|
||||
margin: 0 auto;
|
||||
max-width: 28px;
|
||||
transform-origin: bottom;
|
||||
width: 100%;
|
||||
|
||||
@keyframes barGrow {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export function DashboardBarChart({ data }: { data: DashboardBarChart }) {
|
||||
const max = Math.max(...data.bars.map((bar) => bar.value)) || 1;
|
||||
|
||||
return (
|
||||
<ChartFrame>
|
||||
<PlotArea>
|
||||
<Gridlines $bottom={22} aria-hidden="true">
|
||||
{GRID_LINES.map((line) => (
|
||||
<GridLine key={line} />
|
||||
))}
|
||||
</Gridlines>
|
||||
<BarColumns>
|
||||
{data.bars.map((bar, index) => (
|
||||
<BarColumn key={bar.label}>
|
||||
<BarTrack>
|
||||
<Bar $heightPct={(bar.value / max) * 100} $index={index} />
|
||||
</BarTrack>
|
||||
<AxisLabel>{bar.label}</AxisLabel>
|
||||
</BarColumn>
|
||||
))}
|
||||
</BarColumns>
|
||||
</PlotArea>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Donut chart ------------------------------------------------------------
|
||||
|
||||
const DONUT_RADIUS = 15.915;
|
||||
const DONUT_GAP = 2;
|
||||
|
||||
const DonutWrap = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const DonutFigure = styled.div`
|
||||
flex-shrink: 0;
|
||||
height: 96px;
|
||||
position: relative;
|
||||
width: 96px;
|
||||
`;
|
||||
|
||||
const DonutSvg = styled.svg`
|
||||
display: block;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
width: 100%;
|
||||
|
||||
.donut-slice {
|
||||
animation: donutSliceDraw 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes donutSliceDraw {
|
||||
from {
|
||||
stroke-dasharray: 0 100;
|
||||
}
|
||||
to {
|
||||
stroke-dasharray: var(--slice-pct) var(--slice-rest);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.donut-slice {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DonutCenter = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const DonutValue = styled.span`
|
||||
color: ${COLORS.text};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const DonutLabel = styled.span`
|
||||
color: ${COLORS.textTertiary};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const DonutLegend = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const LegendRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const LegendDot = styled.span<{ $color: string }>`
|
||||
background: ${({ $color }) => $color};
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const LegendLabel = styled.span`
|
||||
color: ${COLORS.textSecondary};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export function DashboardDonutChart({ data }: { data: DashboardDonutChart }) {
|
||||
const total = data.slices.reduce((sum, slice) => sum + slice.value, 0) || 1;
|
||||
let cumulative = 0;
|
||||
|
||||
return (
|
||||
<DonutWrap>
|
||||
<DonutFigure>
|
||||
<DonutSvg viewBox="0 0 36 36" aria-hidden="true">
|
||||
{data.slices.map((slice, index) => {
|
||||
const pct = (slice.value / total) * 100;
|
||||
const drawn = Math.max(pct - DONUT_GAP, 0.5);
|
||||
const offset = -cumulative;
|
||||
cumulative += pct;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={slice.label}
|
||||
className="donut-slice"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r={DONUT_RADIUS}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth="3.4"
|
||||
strokeLinecap="round"
|
||||
pathLength={100}
|
||||
strokeDasharray={`${drawn} ${100 - drawn}`}
|
||||
strokeDashoffset={offset}
|
||||
style={
|
||||
{
|
||||
'--slice-pct': drawn,
|
||||
'--slice-rest': 100 - drawn,
|
||||
animationDelay: `${index * 120}ms`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DonutSvg>
|
||||
<DonutCenter>
|
||||
<DonutValue>{data.centerValue}</DonutValue>
|
||||
<DonutLabel>{data.centerLabel}</DonutLabel>
|
||||
</DonutCenter>
|
||||
</DonutFigure>
|
||||
<DonutLegend>
|
||||
{data.slices.map((slice) => (
|
||||
<LegendRow key={slice.label}>
|
||||
<LegendDot $color={slice.color} />
|
||||
<LegendLabel>{slice.label}</LegendLabel>
|
||||
</LegendRow>
|
||||
))}
|
||||
</DonutLegend>
|
||||
</DonutWrap>
|
||||
);
|
||||
}
|
||||
+185
-89
@@ -1,40 +1,93 @@
|
||||
import type { DashboardChartImage, DashboardPageDefinition } from '../../types';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { VISUAL_TOKENS } from '../../Shared/utils/app-preview-tokens';
|
||||
import { IconTrendingDown, IconTrendingUp } from '@tabler/icons-react';
|
||||
|
||||
const APP_FONT = VISUAL_TOKENS.font.family;
|
||||
const CARD_BACKGROUND = VISUAL_TOKENS.background.secondary;
|
||||
const CARD_BORDER = VISUAL_TOKENS.border.color.light;
|
||||
import type { DashboardKpi, DashboardPageDefinition } from '../../types';
|
||||
import { theme } from '@/theme';
|
||||
import {
|
||||
SkeletonBar,
|
||||
SkeletonBlock,
|
||||
} from '../../Shared/components/PreviewSkeleton';
|
||||
import { APP_FONT, COLORS } from '../../Shared/utils/app-preview-theme';
|
||||
import {
|
||||
DashboardBarChart,
|
||||
DashboardDonutChart,
|
||||
DashboardLineChart,
|
||||
} from './DashboardCharts';
|
||||
|
||||
const TREND_UP_COLOR = '#53b9ab';
|
||||
const TREND_DOWN_COLOR = '#eb8e90';
|
||||
|
||||
const DashboardGrid = styled.div`
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-areas:
|
||||
'metrics'
|
||||
'visits'
|
||||
'revenue'
|
||||
'distribution';
|
||||
'kpis'
|
||||
'line'
|
||||
'bar'
|
||||
'donut';
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-template-areas:
|
||||
'metrics visits visits'
|
||||
'revenue revenue distribution';
|
||||
grid-template-columns: minmax(168px, 252px) minmax(0, 1fr) minmax(
|
||||
168px,
|
||||
252px
|
||||
'kpis line line'
|
||||
'bar bar donut';
|
||||
grid-template-columns: minmax(176px, 248px) minmax(0, 1fr) minmax(
|
||||
176px,
|
||||
248px
|
||||
);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
`;
|
||||
|
||||
const MetricStack = styled.div`
|
||||
align-self: stretch;
|
||||
const WidgetCard = styled.div`
|
||||
animation: dashboardWidgetAppear 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
background: ${COLORS.backgroundSecondary};
|
||||
border: 1px solid ${COLORS.borderLight};
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
|
||||
@keyframes dashboardWidgetAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const WidgetTitle = styled.span`
|
||||
color: ${COLORS.text};
|
||||
flex-shrink: 0;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
padding: 0 2px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const KpiStack = styled.div`
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-area: metrics;
|
||||
grid-area: kpis;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
@@ -42,76 +95,87 @@ const MetricStack = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const MetricCard = styled.div`
|
||||
background: ${CARD_BACKGROUND};
|
||||
border: 1px solid ${CARD_BORDER};
|
||||
border-radius: 8px;
|
||||
const KpiCard = styled(WidgetCard)`
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const KpiValueRow = styled.div`
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-height: 92px;
|
||||
padding: 12px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const MetricTitle = styled.span`
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
const KpiValue = styled.span`
|
||||
color: ${COLORS.text};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const MetricValue = styled.span`
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 24px;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
line-height: 1.1;
|
||||
`;
|
||||
|
||||
const VisitsChart = styled.div`
|
||||
grid-area: visits;
|
||||
min-width: 0;
|
||||
const KpiTrend = styled.span<{ $up: boolean }>`
|
||||
align-items: center;
|
||||
color: ${({ $up }) => ($up ? TREND_UP_COLOR : TREND_DOWN_COLOR)};
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const RevenueChart = styled.div`
|
||||
grid-area: revenue;
|
||||
min-width: 0;
|
||||
const LineCard = styled(WidgetCard)`
|
||||
grid-area: line;
|
||||
`;
|
||||
|
||||
const DistributionChart = styled.div`
|
||||
grid-area: distribution;
|
||||
min-width: 0;
|
||||
const BarCard = styled(WidgetCard)`
|
||||
grid-area: bar;
|
||||
`;
|
||||
|
||||
const ChartImage = styled.img`
|
||||
display: block;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
const DonutCard = styled(WidgetCard)`
|
||||
grid-area: donut;
|
||||
`;
|
||||
|
||||
function DashboardChart({
|
||||
chart,
|
||||
className,
|
||||
}: {
|
||||
chart: DashboardChartImage;
|
||||
className?: string;
|
||||
}) {
|
||||
function KpiWidget({ kpi }: { kpi: DashboardKpi }) {
|
||||
const isUp = kpi.trend?.direction === 'up';
|
||||
|
||||
return (
|
||||
<ChartImage
|
||||
alt={chart.alt}
|
||||
className={className}
|
||||
height={chart.height}
|
||||
loading="eager"
|
||||
src={chart.src}
|
||||
width={chart.width}
|
||||
/>
|
||||
<KpiCard>
|
||||
<WidgetTitle>{kpi.title}</WidgetTitle>
|
||||
<KpiValueRow>
|
||||
<KpiValue>{kpi.value}</KpiValue>
|
||||
{kpi.trend ? (
|
||||
<KpiTrend $up={isUp}>
|
||||
{isUp ? (
|
||||
<IconTrendingUp size={14} stroke={2} />
|
||||
) : (
|
||||
<IconTrendingDown size={14} stroke={2} />
|
||||
)}
|
||||
{kpi.trend.value}
|
||||
</KpiTrend>
|
||||
) : null}
|
||||
</KpiValueRow>
|
||||
</KpiCard>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiSkeleton() {
|
||||
return (
|
||||
<KpiCard>
|
||||
<SkeletonBar $height={11} $width="55%" />
|
||||
<SkeletonBar $height={20} $width="45%" />
|
||||
</KpiCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<SkeletonBar $height={11} $width="48%" />
|
||||
<SkeletonBlock />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,28 +184,60 @@ export function SalesDashboardPage({
|
||||
}: {
|
||||
page: DashboardPageDefinition;
|
||||
}) {
|
||||
const { kpis, lineChart, barChart, donutChart, generating } = page.dashboard;
|
||||
|
||||
return (
|
||||
<DashboardGrid>
|
||||
<MetricStack>
|
||||
{page.dashboard.metrics.map((metric) => (
|
||||
<MetricCard key={metric.id}>
|
||||
<MetricTitle>{metric.title}</MetricTitle>
|
||||
<MetricValue>{metric.value}</MetricValue>
|
||||
</MetricCard>
|
||||
))}
|
||||
</MetricStack>
|
||||
{kpis.length > 0 ? (
|
||||
<KpiStack>
|
||||
{kpis.map((kpi) =>
|
||||
generating ? (
|
||||
<KpiSkeleton key={kpi.id} />
|
||||
) : (
|
||||
<KpiWidget key={kpi.id} kpi={kpi} />
|
||||
),
|
||||
)}
|
||||
</KpiStack>
|
||||
) : null}
|
||||
|
||||
<VisitsChart>
|
||||
<DashboardChart chart={page.dashboard.visitsChart} />
|
||||
</VisitsChart>
|
||||
{lineChart ? (
|
||||
<LineCard>
|
||||
{generating ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<WidgetTitle>{lineChart.title}</WidgetTitle>
|
||||
<DashboardLineChart data={lineChart} />
|
||||
</>
|
||||
)}
|
||||
</LineCard>
|
||||
) : null}
|
||||
|
||||
<RevenueChart>
|
||||
<DashboardChart chart={page.dashboard.revenueChart} />
|
||||
</RevenueChart>
|
||||
{barChart ? (
|
||||
<BarCard>
|
||||
{generating ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<WidgetTitle>{barChart.title}</WidgetTitle>
|
||||
<DashboardBarChart data={barChart} />
|
||||
</>
|
||||
)}
|
||||
</BarCard>
|
||||
) : null}
|
||||
|
||||
<DistributionChart>
|
||||
<DashboardChart chart={page.dashboard.distributionChart} />
|
||||
</DistributionChart>
|
||||
{donutChart ? (
|
||||
<DonutCard>
|
||||
{generating ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<WidgetTitle>{donutChart.title}</WidgetTitle>
|
||||
<DashboardDonutChart data={donutChart} />
|
||||
</>
|
||||
)}
|
||||
</DonutCard>
|
||||
) : null}
|
||||
</DashboardGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { CellEntity, CellPerson, KanbanCard } from '../../types';
|
||||
import { FaviconLogo } from '../../Shared/components/FaviconLogo';
|
||||
import { PersonAvatar } from '../../Shared/components/PersonAvatar';
|
||||
import { Chip } from '../../Shared/components/Chip';
|
||||
import { ChipVariant } from '../../Shared/utils/chip-variant';
|
||||
import {
|
||||
KANBAN_PAGE_COLORS,
|
||||
KANBAN_PAGE_FONT,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from './kanban-page-theme';
|
||||
|
||||
const Card = styled.div`
|
||||
animation: kanbanCardAppear 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
background: ${KANBAN_PAGE_COLORS.backgroundSecondary};
|
||||
border: 1px solid ${KANBAN_PAGE_COLORS.border};
|
||||
border-radius: 4px;
|
||||
@@ -30,6 +32,17 @@ const Card = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@keyframes kanbanCardAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
@@ -145,6 +158,7 @@ function EntityChip({ entity }: { entity: CellEntity }) {
|
||||
label={entity.name}
|
||||
leftComponent={<FaviconLogo domain={entity.domain} label={entity.name} />}
|
||||
maxWidth={152}
|
||||
variant={ChipVariant.Highlighted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -156,6 +170,7 @@ function PersonChip({ person }: { person: CellPerson }) {
|
||||
label={person.name}
|
||||
leftComponent={<PersonAvatar person={person} />}
|
||||
maxWidth={152}
|
||||
variant={ChipVariant.Highlighted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import { styled } from '@linaria/react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
|
||||
import type { KanbanLane } from '../../types';
|
||||
import {
|
||||
SkeletonBar,
|
||||
SkeletonCircle,
|
||||
} from '../../Shared/components/PreviewSkeleton';
|
||||
import { KanbanCard } from './KanbanCard';
|
||||
import {
|
||||
KANBAN_LANE_TONES,
|
||||
@@ -88,16 +92,66 @@ const AddCardButton = styled.div`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const SkeletonCardShell = styled.div<{ $index: number }>`
|
||||
animation: kanbanSkeletonCardAppear 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: ${({ $index }) => `${$index * 90}ms`};
|
||||
background: ${KANBAN_PAGE_COLORS.backgroundSecondary};
|
||||
border: 1px solid ${KANBAN_PAGE_COLORS.border};
|
||||
border-radius: 4px;
|
||||
box-shadow: ${KANBAN_PAGE_COLORS.shadow};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 8px;
|
||||
|
||||
@keyframes kanbanSkeletonCardAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SkeletonCardField = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SKELETON_FIELD_WIDTHS = ['70%', '54%', '62%'];
|
||||
|
||||
function SkeletonCard({ index }: { index: number }) {
|
||||
return (
|
||||
<SkeletonCardShell $index={index}>
|
||||
<SkeletonBar $height={9} $width="64%" />
|
||||
{SKELETON_FIELD_WIDTHS.map((width, fieldIndex) => (
|
||||
<SkeletonCardField key={fieldIndex}>
|
||||
<SkeletonCircle $size={16} />
|
||||
<SkeletonBar $height={8} $width={width} />
|
||||
</SkeletonCardField>
|
||||
))}
|
||||
</SkeletonCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanLane({
|
||||
lane,
|
||||
index = 0,
|
||||
isLast,
|
||||
generating = false,
|
||||
}: {
|
||||
generating?: boolean;
|
||||
index?: number;
|
||||
isLast: boolean;
|
||||
lane: KanbanLane;
|
||||
}) {
|
||||
const tone = KANBAN_LANE_TONES[lane.tone] ?? KANBAN_LANE_TONES.gray;
|
||||
const skeletonCardCount = 2 + (index % 2);
|
||||
|
||||
return (
|
||||
<Lane $index={index} $last={isLast}>
|
||||
@@ -105,13 +159,15 @@ export function KanbanLane({
|
||||
<LaneTag $background={tone.background} $color={tone.color}>
|
||||
{lane.label}
|
||||
</LaneTag>
|
||||
<LaneCount>{lane.cards.length}</LaneCount>
|
||||
{generating ? null : <LaneCount>{lane.cards.length}</LaneCount>}
|
||||
</LaneHeader>
|
||||
|
||||
<LaneBody>
|
||||
{lane.cards.map((card) => (
|
||||
<KanbanCard key={card.id} card={card} />
|
||||
))}
|
||||
{generating
|
||||
? Array.from({ length: skeletonCardCount }, (_, cardIndex) => (
|
||||
<SkeletonCard key={cardIndex} index={cardIndex} />
|
||||
))
|
||||
: lane.cards.map((card) => <KanbanCard key={card.id} card={card} />)}
|
||||
|
||||
<AddCardButton aria-hidden="true">
|
||||
<IconPlus
|
||||
|
||||
@@ -41,6 +41,7 @@ export function KanbanPage({ page }: { page: KanbanPageDefinition }) {
|
||||
{page.lanes.map((lane, index) => (
|
||||
<KanbanLane
|
||||
key={lane.id}
|
||||
generating={page.generating}
|
||||
index={index}
|
||||
isLast={index === page.lanes.length - 1}
|
||||
lane={lane}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,10 @@ import { useState } from 'react';
|
||||
import type { TablePageDefinition } from '../../types';
|
||||
import { IconChevronDown, IconPlus } from '@tabler/icons-react';
|
||||
import { MiniIcon } from '../../Shared/components/MiniIcon';
|
||||
import {
|
||||
SkeletonBar,
|
||||
SkeletonCircle,
|
||||
} from '../../Shared/components/PreviewSkeleton';
|
||||
import { TablePageCheckbox } from './TablePageCheckbox';
|
||||
import { renderTableCellValue } from './TablePageCellValue';
|
||||
import { renderTableHeaderIcon } from './render-table-header-icon';
|
||||
@@ -29,19 +33,6 @@ const TableShell = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const GripRail = styled.div`
|
||||
background: ${COLORS.background};
|
||||
display: grid;
|
||||
flex: 0 0 12px;
|
||||
grid-auto-rows: 32px;
|
||||
width: 12px;
|
||||
`;
|
||||
|
||||
const GripCell = styled.div`
|
||||
background: ${COLORS.background};
|
||||
border-bottom: 1px solid ${COLORS.borderLight};
|
||||
`;
|
||||
|
||||
const TableViewport = styled.div<{ $dragging: boolean }>`
|
||||
cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')};
|
||||
flex: 1 1 auto;
|
||||
@@ -87,15 +78,17 @@ const DataRow = styled.div<{ $rowIndex: number }>`
|
||||
animation: tableRowAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: ${({ $rowIndex }) => `${120 + $rowIndex * 70}ms`};
|
||||
display: flex;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@keyframes tableRowAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
max-height: 32px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -129,7 +122,13 @@ const TableCell = styled.div<{
|
||||
$align === 'right' ? 'flex-end' : 'flex-start'};
|
||||
left: ${({ $sticky }) => ($sticky ? '0' : 'auto')};
|
||||
min-width: ${({ $width }) => `${$width}px`};
|
||||
padding: 0 ${TABLE_CELL_HORIZONTAL_PADDING}px;
|
||||
padding-bottom: 0;
|
||||
padding-right: ${TABLE_CELL_HORIZONTAL_PADDING}px;
|
||||
padding-top: 0;
|
||||
padding-left: ${({ $sticky }) =>
|
||||
$sticky
|
||||
? `${TABLE_CELL_HORIZONTAL_PADDING - 1}px`
|
||||
: `${TABLE_CELL_HORIZONTAL_PADDING}px`};
|
||||
position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')};
|
||||
z-index: ${({ $header, $sticky }) => {
|
||||
if ($sticky && $header) {
|
||||
@@ -210,12 +209,21 @@ const HeaderFillContent = styled.div`
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const SKELETON_ROW_INDEXES = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
const SkeletonRowLead = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function TablePage({
|
||||
page,
|
||||
onNavigateToLabel,
|
||||
onNavigateToPageItemId,
|
||||
}: {
|
||||
page: TablePageDefinition;
|
||||
onNavigateToLabel?: (label: string) => void;
|
||||
onNavigateToPageItemId?: (itemId: string) => void;
|
||||
}) {
|
||||
const {
|
||||
dragging,
|
||||
@@ -237,14 +245,6 @@ export function TablePage({
|
||||
|
||||
return (
|
||||
<TableShell>
|
||||
<GripRail aria-hidden="true">
|
||||
<GripCell />
|
||||
{page.rows.map((row) => (
|
||||
<GripCell key={`grip-${row.id}`} />
|
||||
))}
|
||||
<GripCell />
|
||||
</GripRail>
|
||||
|
||||
<TableViewport
|
||||
ref={viewportRef}
|
||||
$dragging={dragging}
|
||||
@@ -301,47 +301,71 @@ export function TablePage({
|
||||
</EmptyFillCell>
|
||||
</HeaderRow>
|
||||
|
||||
{page.rows.map((row, rowIndex) => {
|
||||
const hovered = hoveredRowId === row.id;
|
||||
|
||||
return (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
$rowIndex={rowIndex}
|
||||
onMouseEnter={() => setHoveredRowId(row.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredRowId((current) =>
|
||||
current === row.id ? null : current,
|
||||
)
|
||||
}
|
||||
>
|
||||
{page.columns.map((column) => {
|
||||
const cell = row.cells[column.id];
|
||||
|
||||
return (
|
||||
{page.generating
|
||||
? SKELETON_ROW_INDEXES.map((rowIndex) => (
|
||||
<DataRow key={`skeleton-${rowIndex}`} $rowIndex={rowIndex}>
|
||||
{page.columns.map((column) => (
|
||||
<TableCell
|
||||
key={`${row.id}-${column.id}`}
|
||||
key={column.id}
|
||||
$align={column.align}
|
||||
$hovered={hovered}
|
||||
$sticky={column.isFirstColumn}
|
||||
$width={column.width}
|
||||
>
|
||||
{cell
|
||||
? renderTableCellValue({
|
||||
cell,
|
||||
columnId: column.id,
|
||||
hovered,
|
||||
isFirstColumn: !!column.isFirstColumn,
|
||||
onNavigateToLabel,
|
||||
})
|
||||
: null}
|
||||
{column.isFirstColumn ? (
|
||||
<SkeletonRowLead>
|
||||
<SkeletonCircle $size={16} />
|
||||
<SkeletonBar $height={8} $width="58%" />
|
||||
</SkeletonRowLead>
|
||||
) : (
|
||||
<SkeletonBar $height={8} $width="56%" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
|
||||
</DataRow>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
<EmptyFillCell $width={fillerWidth} />
|
||||
</DataRow>
|
||||
))
|
||||
: page.rows.map((row, rowIndex) => {
|
||||
const hovered = hoveredRowId === row.id;
|
||||
|
||||
return (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
$rowIndex={rowIndex}
|
||||
data-row-id={row.id}
|
||||
onMouseEnter={() => setHoveredRowId(row.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredRowId((current) =>
|
||||
current === row.id ? null : current,
|
||||
)
|
||||
}
|
||||
>
|
||||
{page.columns.map((column) => {
|
||||
const cell = row.cells[column.id];
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={`${row.id}-${column.id}`}
|
||||
$align={column.align}
|
||||
$hovered={hovered}
|
||||
$sticky={column.isFirstColumn}
|
||||
$width={column.width}
|
||||
>
|
||||
{cell
|
||||
? renderTableCellValue({
|
||||
cell,
|
||||
columnId: column.id,
|
||||
hovered,
|
||||
isFirstColumn: !!column.isFirstColumn,
|
||||
onNavigateToPageItemId,
|
||||
})
|
||||
: null}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
|
||||
</DataRow>
|
||||
);
|
||||
})}
|
||||
|
||||
<FooterRow>
|
||||
{page.columns.length > 0 ? (
|
||||
|
||||
+104
-49
@@ -3,9 +3,12 @@ import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
CellCurrency,
|
||||
CellEntity,
|
||||
CellLink,
|
||||
CellPerson,
|
||||
CellRelation,
|
||||
CellSelect,
|
||||
CellText,
|
||||
CellValue,
|
||||
} from '../../types';
|
||||
@@ -19,6 +22,8 @@ import { VISUAL_TOKENS } from '../../Shared/utils/app-preview-tokens';
|
||||
import { IconCheck, IconCopy, IconPencil, IconX } from '@tabler/icons-react';
|
||||
import { MiniIcon } from '../../Shared/components/MiniIcon';
|
||||
import { TablePageCheckbox } from './TablePageCheckbox';
|
||||
import { PreviewRoundedLink } from '../../Shared/components/PreviewRoundedLink';
|
||||
import { PreviewTag } from '../../Shared/components/PreviewTag';
|
||||
import {
|
||||
TABLE_PAGE_CELL_HORIZONTAL_PADDING,
|
||||
TABLE_PAGE_COLORS,
|
||||
@@ -31,7 +36,7 @@ const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([
|
||||
'accountOwner',
|
||||
]);
|
||||
|
||||
const EntityCellLayout = styled.div`
|
||||
const FirstColumnCellLayout = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -115,25 +120,6 @@ const MiniAction = styled.div`
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const TagChip = styled.div`
|
||||
align-items: center;
|
||||
background: ${VISUAL_TOKENS.background.transparent.light};
|
||||
border-radius: 4px;
|
||||
color: ${TABLE_PAGE_COLORS.textSecondary};
|
||||
display: inline-flex;
|
||||
font-family: ${TABLE_PAGE_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.regular};
|
||||
height: 20px;
|
||||
line-height: 1.4;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const MultiChipStack = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -144,21 +130,49 @@ const MultiChipStack = styled.div`
|
||||
`;
|
||||
|
||||
function PersonTokenCell({
|
||||
isFirstColumn = false,
|
||||
token,
|
||||
hovered = false,
|
||||
variant = ChipVariant.Highlighted,
|
||||
withCopyAction = true,
|
||||
}: {
|
||||
hovered?: boolean;
|
||||
isFirstColumn?: boolean;
|
||||
token: CellPerson;
|
||||
variant?: ChipVariant;
|
||||
withCopyAction?: boolean;
|
||||
}) {
|
||||
const content = (
|
||||
<CellChip
|
||||
clickable={false}
|
||||
label={token.name}
|
||||
leftComponent={<PersonAvatar person={token} />}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isFirstColumn) {
|
||||
return (
|
||||
<FirstColumnCellLayout>
|
||||
<TablePageCheckbox />
|
||||
{content}
|
||||
<HoverActions $visible={hovered}>
|
||||
{withCopyAction ? (
|
||||
<MiniAction aria-hidden="true">
|
||||
<MiniIcon
|
||||
icon={IconCopy}
|
||||
color={TABLE_PAGE_COLORS.textSecondary}
|
||||
/>
|
||||
</MiniAction>
|
||||
) : null}
|
||||
</HoverActions>
|
||||
</FirstColumnCellLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellHoverAnchor>
|
||||
<CellChip
|
||||
clickable={false}
|
||||
label={token.name}
|
||||
leftComponent={<PersonAvatar person={token} />}
|
||||
/>
|
||||
{content}
|
||||
<HoverActions $visible={hovered}>
|
||||
{withCopyAction ? (
|
||||
<MiniAction aria-hidden="true">
|
||||
@@ -181,7 +195,7 @@ function EntityCellComponent({
|
||||
}) {
|
||||
if (isFirstColumn) {
|
||||
return (
|
||||
<EntityCellLayout>
|
||||
<FirstColumnCellLayout>
|
||||
<TablePageCheckbox />
|
||||
<CellChip
|
||||
clickable={false}
|
||||
@@ -197,7 +211,7 @@ function EntityCellComponent({
|
||||
/>
|
||||
</MiniAction>
|
||||
</HoverActions>
|
||||
</EntityCellLayout>
|
||||
</FirstColumnCellLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +220,7 @@ function EntityCellComponent({
|
||||
clickable={false}
|
||||
label={cell.name}
|
||||
leftComponent={<FaviconLogo domain={cell.domain} label={cell.name} />}
|
||||
variant={ChipVariant.Highlighted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -230,6 +245,7 @@ function RelationCellComponent({
|
||||
{item.shortLabel ?? getInitials(item.name)}
|
||||
</PreviewAvatar>
|
||||
}
|
||||
variant={ChipVariant.Highlighted}
|
||||
/>
|
||||
))}
|
||||
</MultiChipStack>
|
||||
@@ -245,23 +261,23 @@ function RelationCellComponent({
|
||||
function TextCellComponent({
|
||||
cell,
|
||||
isFirstColumn,
|
||||
onNavigateToLabel,
|
||||
onNavigateToPageItemId,
|
||||
}: {
|
||||
cell: CellText;
|
||||
isFirstColumn: boolean;
|
||||
onNavigateToLabel?: (label: string) => void;
|
||||
onNavigateToPageItemId?: (itemId: string) => void;
|
||||
}) {
|
||||
const targetLabel = cell.targetLabel;
|
||||
const targetPageItemId = cell.targetPageItemId;
|
||||
const handleNavigate =
|
||||
targetLabel && onNavigateToLabel
|
||||
? () => onNavigateToLabel(targetLabel)
|
||||
targetPageItemId && onNavigateToPageItemId
|
||||
? () => onNavigateToPageItemId(targetPageItemId)
|
||||
: undefined;
|
||||
|
||||
if (!isFirstColumn || !cell.shortLabel) {
|
||||
if (!isFirstColumn) {
|
||||
return <InlineText>{cell.value}</InlineText>;
|
||||
}
|
||||
|
||||
return (
|
||||
const content = cell.shortLabel ? (
|
||||
<CellChip
|
||||
clickable={handleNavigate !== undefined}
|
||||
label={cell.value}
|
||||
@@ -269,8 +285,44 @@ function TextCellComponent({
|
||||
<PreviewAvatar tone={cell.tone}>{cell.shortLabel}</PreviewAvatar>
|
||||
}
|
||||
onClick={handleNavigate}
|
||||
variant={ChipVariant.Highlighted}
|
||||
/>
|
||||
) : (
|
||||
<CellChip
|
||||
clickable={false}
|
||||
label={cell.value}
|
||||
variant={ChipVariant.Highlighted}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<FirstColumnCellLayout>
|
||||
<TablePageCheckbox />
|
||||
{content}
|
||||
</FirstColumnCellLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkCellComponent({ cell }: { cell: CellLink }) {
|
||||
const label =
|
||||
cell.label ??
|
||||
(cell.kind === 'social' && cell.value.startsWith('@')
|
||||
? cell.value
|
||||
: cell.value);
|
||||
|
||||
return (
|
||||
<CellHoverAnchor>
|
||||
<PreviewRoundedLink label={label} />
|
||||
</CellHoverAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrencyCellComponent({ cell }: { cell: CellCurrency }) {
|
||||
return <RightAlignedText>{cell.value}</RightAlignedText>;
|
||||
}
|
||||
|
||||
function SelectCellComponent({ cell }: { cell: CellSelect }) {
|
||||
return <PreviewTag color={cell.color} label={cell.value} />;
|
||||
}
|
||||
|
||||
export function renderTableCellValue({
|
||||
@@ -278,15 +330,19 @@ export function renderTableCellValue({
|
||||
columnId,
|
||||
hovered,
|
||||
isFirstColumn,
|
||||
onNavigateToLabel,
|
||||
onNavigateToPageItemId,
|
||||
}: {
|
||||
cell: CellValue;
|
||||
columnId: string;
|
||||
hovered: boolean;
|
||||
isFirstColumn: boolean;
|
||||
onNavigateToLabel?: (label: string) => void;
|
||||
onNavigateToPageItemId?: (itemId: string) => void;
|
||||
}): ReactNode {
|
||||
const showHoverAction = !ROW_HOVER_ACTION_DISABLED_COLUMNS.has(columnId);
|
||||
const personChipVariant =
|
||||
columnId === 'createdBy'
|
||||
? ChipVariant.Transparent
|
||||
: ChipVariant.Highlighted;
|
||||
|
||||
switch (cell.type) {
|
||||
case 'text':
|
||||
@@ -294,21 +350,15 @@ export function renderTableCellValue({
|
||||
<TextCellComponent
|
||||
cell={cell}
|
||||
isFirstColumn={isFirstColumn}
|
||||
onNavigateToLabel={onNavigateToLabel}
|
||||
onNavigateToPageItemId={onNavigateToPageItemId}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return <RightAlignedText>{cell.value}</RightAlignedText>;
|
||||
case 'currency':
|
||||
return <CurrencyCellComponent cell={cell} />;
|
||||
case 'link':
|
||||
return (
|
||||
<div style={{ minWidth: 0, position: 'relative', width: '100%' }}>
|
||||
<CellChip
|
||||
clickable={false}
|
||||
label={cell.value}
|
||||
variant={ChipVariant.Static}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <LinkCellComponent cell={cell} />;
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanRow>
|
||||
@@ -324,11 +374,16 @@ export function renderTableCellValue({
|
||||
<InlineText>{cell.value ? 'True' : 'False'}</InlineText>
|
||||
</BooleanRow>
|
||||
);
|
||||
case 'tag':
|
||||
return <TagChip>{cell.value}</TagChip>;
|
||||
case 'select':
|
||||
return <SelectCellComponent cell={cell} />;
|
||||
case 'person':
|
||||
return (
|
||||
<PersonTokenCell hovered={hovered && showHoverAction} token={cell} />
|
||||
<PersonTokenCell
|
||||
hovered={hovered && showHoverAction}
|
||||
isFirstColumn={isFirstColumn}
|
||||
token={cell}
|
||||
variant={personChipVariant}
|
||||
/>
|
||||
);
|
||||
case 'entity':
|
||||
return (
|
||||
|
||||
@@ -18,9 +18,7 @@ const CheckboxBox = styled.div<{ $checked?: boolean }>`
|
||||
$checked ? TABLE_PAGE_COLORS.accentSurfaceSoft : 'transparent'};
|
||||
border: 1px solid
|
||||
${({ $checked }) =>
|
||||
$checked
|
||||
? TABLE_PAGE_COLORS.accentBorder
|
||||
: TABLE_PAGE_COLORS.borderStrong};
|
||||
$checked ? TABLE_PAGE_COLORS.accentBorder : TABLE_PAGE_COLORS.text};
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
+29
-9
@@ -1,4 +1,3 @@
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { WorkflowBranchLabel as WorkflowBranchLabelDefinition } from './workflow-page-data';
|
||||
@@ -7,22 +6,39 @@ import {
|
||||
WORKFLOW_PAGE_FONT,
|
||||
} from './workflow-page-theme';
|
||||
|
||||
const BranchLabel = styled.div`
|
||||
const BranchLabel = styled.div<{ $centered?: boolean }>`
|
||||
align-items: center;
|
||||
animation: workflowBranchLabelAppear 320ms ease both;
|
||||
animation-delay: 700ms;
|
||||
background: ${WORKFLOW_PAGE_COLORS.nodeSurface};
|
||||
border: 1px solid ${WORKFLOW_PAGE_COLORS.nodeBorder};
|
||||
border-radius: 6px;
|
||||
color: ${WORKFLOW_PAGE_COLORS.textTertiary};
|
||||
border-radius: 4px;
|
||||
color: ${WORKFLOW_PAGE_COLORS.textLight};
|
||||
display: inline-flex;
|
||||
font-family: ${WORKFLOW_PAGE_FONT};
|
||||
font-size: 11px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
height: 22px;
|
||||
font-weight: 600;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
padding: 0 6px;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
position: absolute;
|
||||
transform: ${({ $centered }) =>
|
||||
$centered ? 'translate(-50%, -50%)' : 'none'};
|
||||
z-index: 2;
|
||||
|
||||
@keyframes workflowBranchLabelAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export function WorkflowBranchLabel({
|
||||
@@ -30,5 +46,9 @@ export function WorkflowBranchLabel({
|
||||
x,
|
||||
y,
|
||||
}: WorkflowBranchLabelDefinition) {
|
||||
return <BranchLabel style={{ left: x, top: y }}>{text}</BranchLabel>;
|
||||
return (
|
||||
<BranchLabel $centered={text === 'completed'} style={{ left: x, top: y }}>
|
||||
{text}
|
||||
</BranchLabel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type {
|
||||
@@ -6,8 +8,6 @@ import type {
|
||||
} from './workflow-page-data';
|
||||
import { getWorkflowEdgePath } from './workflow-page-geometry';
|
||||
import {
|
||||
WORKFLOW_CANVAS_HEIGHT,
|
||||
WORKFLOW_CANVAS_WIDTH,
|
||||
WORKFLOW_NODE_HEIGHT,
|
||||
WORKFLOW_PAGE_COLORS,
|
||||
} from './workflow-page-theme';
|
||||
@@ -20,28 +20,132 @@ const CanvasOverlay = styled.svg`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const EdgePath = styled.path`
|
||||
animation: workflowEdgeDraw 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
stroke-dasharray: 1;
|
||||
|
||||
@keyframes workflowEdgeDraw {
|
||||
from {
|
||||
opacity: 0;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ConnectorPath = styled.path`
|
||||
animation: workflowConnectorAppear 360ms ease both;
|
||||
animation-delay: 620ms;
|
||||
|
||||
@keyframes workflowConnectorAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const PortsLayer = styled.g`
|
||||
animation: workflowPortsAppear 360ms ease both;
|
||||
animation-delay: 520ms;
|
||||
|
||||
@keyframes workflowPortsAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export function WorkflowEdges({
|
||||
canvasHeight,
|
||||
canvasWidth,
|
||||
edges,
|
||||
nodes,
|
||||
plusNode,
|
||||
}: {
|
||||
canvasHeight: number;
|
||||
canvasWidth: number;
|
||||
edges: ReadonlyArray<WorkflowEdgeDefinition>;
|
||||
nodes: ReadonlyArray<WorkflowNodeDefinition>;
|
||||
plusNode?: { x: number; y: number };
|
||||
}) {
|
||||
const arrowId = `workflow-arrow-${useId().replace(/:/g, '')}`;
|
||||
const completedEdge = edges.find((e) => e.type === 'loopRight');
|
||||
const completedSourceNode = completedEdge
|
||||
? getWorkflowNodeById(nodes, completedEdge.from)
|
||||
: undefined;
|
||||
const topPortNodeIds = new Set<string>();
|
||||
const bottomPortNodeIds = new Set<string>();
|
||||
const rightPortNodeIds = new Set<string>();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.type === 'loopRight') {
|
||||
rightPortNodeIds.add(edge.from);
|
||||
topPortNodeIds.add(edge.to);
|
||||
continue;
|
||||
}
|
||||
|
||||
bottomPortNodeIds.add(edge.from);
|
||||
|
||||
if (edge.type !== 'loopBack') {
|
||||
topPortNodeIds.add(edge.to);
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSourceNode && plusNode) {
|
||||
bottomPortNodeIds.add(completedSourceNode.id);
|
||||
}
|
||||
|
||||
const completedConnectorPath =
|
||||
plusNode && completedSourceNode
|
||||
? (() => {
|
||||
const startX = completedSourceNode.x + completedSourceNode.width / 2;
|
||||
const startY = completedSourceNode.y + WORKFLOW_NODE_HEIGHT + 1;
|
||||
const endX = plusNode.x;
|
||||
const endY = plusNode.y;
|
||||
const elbowY = endY - 14;
|
||||
|
||||
if (Math.abs(startX - endX) < 1) {
|
||||
return `M${startX} ${startY} L${endX} ${endY}`;
|
||||
}
|
||||
|
||||
return [
|
||||
`M${startX} ${startY}`,
|
||||
`L${startX} ${elbowY}`,
|
||||
`L${endX} ${elbowY}`,
|
||||
`L${endX} ${endY}`,
|
||||
].join(' ');
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CanvasOverlay
|
||||
aria-hidden
|
||||
viewBox={`0 0 ${WORKFLOW_CANVAS_WIDTH} ${WORKFLOW_CANVAS_HEIGHT}`}
|
||||
>
|
||||
<CanvasOverlay aria-hidden viewBox={`0 0 ${canvasWidth} ${canvasHeight}`}>
|
||||
<defs>
|
||||
<marker
|
||||
id="workflow-arrow"
|
||||
id={arrowId}
|
||||
markerHeight="6"
|
||||
markerWidth="6"
|
||||
orient="auto"
|
||||
@@ -58,65 +162,74 @@ export function WorkflowEdges({
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map((edge) => (
|
||||
<path
|
||||
{edges.map((edge, index) => (
|
||||
<EdgePath
|
||||
key={`${edge.from}-${edge.to}-${edge.type}`}
|
||||
d={getWorkflowEdgePath({ edge, nodes })}
|
||||
fill="none"
|
||||
markerEnd={
|
||||
edge.type === 'loopBack' ? undefined : 'url(#workflow-arrow)'
|
||||
}
|
||||
markerEnd={edge.type === 'loopBack' ? undefined : `url(#${arrowId})`}
|
||||
pathLength={1}
|
||||
stroke={WORKFLOW_PAGE_COLORS.arrowStroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
style={{ animationDelay: `${140 + index * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
{plusNode && completedSourceNode && (
|
||||
<path
|
||||
d={`M${completedSourceNode.x + completedSourceNode.width / 2} ${completedSourceNode.y + WORKFLOW_NODE_HEIGHT + 1} L${plusNode.x} ${plusNode.y}`}
|
||||
{completedConnectorPath ? (
|
||||
<ConnectorPath
|
||||
d={completedConnectorPath}
|
||||
fill="none"
|
||||
stroke={WORKFLOW_PAGE_COLORS.arrowStroke}
|
||||
strokeDasharray="4 3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
{nodes.map((node) => {
|
||||
const cx = node.x + node.width / 2;
|
||||
const bottomY = node.y + WORKFLOW_NODE_HEIGHT + 1;
|
||||
const rightX = node.x + node.width;
|
||||
const midY = node.y + WORKFLOW_NODE_HEIGHT / 2;
|
||||
) : null}
|
||||
<PortsLayer>
|
||||
{nodes.map((node) => {
|
||||
const cx = node.x + node.width / 2;
|
||||
const bottomY = node.y + WORKFLOW_NODE_HEIGHT + 1;
|
||||
const rightX = node.x + node.width;
|
||||
const midY = node.y + WORKFLOW_NODE_HEIGHT / 2;
|
||||
|
||||
return (
|
||||
<g key={`ports-${node.id}`}>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={node.y}
|
||||
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
|
||||
r="4"
|
||||
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={bottomY}
|
||||
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
|
||||
r="4"
|
||||
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<circle
|
||||
cx={rightX}
|
||||
cy={midY}
|
||||
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
|
||||
r="4"
|
||||
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<g key={`ports-${node.id}`}>
|
||||
{topPortNodeIds.has(node.id) ? (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={node.y}
|
||||
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
|
||||
r="4"
|
||||
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
) : null}
|
||||
{bottomPortNodeIds.has(node.id) ? (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={bottomY}
|
||||
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
|
||||
r="4"
|
||||
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
) : null}
|
||||
{rightPortNodeIds.has(node.id) ? (
|
||||
<circle
|
||||
cx={rightX}
|
||||
cy={midY}
|
||||
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
|
||||
r="4"
|
||||
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</PortsLayer>
|
||||
</CanvasOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { theme } from '@/theme';
|
||||
import {
|
||||
SkeletonBar,
|
||||
SkeletonBlock,
|
||||
} from '../../Shared/components/PreviewSkeleton';
|
||||
import type { WorkflowNodeDefinition } from './workflow-page-data';
|
||||
import {
|
||||
WORKFLOW_NODE_HEIGHT,
|
||||
@@ -9,6 +14,24 @@ import {
|
||||
WORKFLOW_PAGE_TABLER_STROKE,
|
||||
} from './workflow-page-theme';
|
||||
|
||||
const nodeContentAppear = css`
|
||||
animation: workflowNodeContentAppear 320ms ease both;
|
||||
animation-delay: 80ms;
|
||||
|
||||
@keyframes workflowNodeContentAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Node = styled.div<{ $index: number }>`
|
||||
align-items: center;
|
||||
animation: workflowNodeAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
@@ -49,6 +72,13 @@ const NodeIconContainer = styled.div`
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const SkeletonIcon = styled(SkeletonBlock)`
|
||||
border-radius: 4px;
|
||||
flex: 0 0 auto;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const NodeContent = styled.div`
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
@@ -62,7 +92,7 @@ const NodeContent = styled.div`
|
||||
`;
|
||||
|
||||
const NodeLabel = styled.div`
|
||||
color: ${WORKFLOW_PAGE_COLORS.textTertiary};
|
||||
color: ${WORKFLOW_PAGE_COLORS.textLight};
|
||||
font-family: ${WORKFLOW_PAGE_FONT};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -89,21 +119,37 @@ export function WorkflowNode({
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
}: Omit<WorkflowNodeDefinition, 'id'> & { index?: number }) {
|
||||
generating = false,
|
||||
}: Omit<WorkflowNodeDefinition, 'id'> & {
|
||||
generating?: boolean;
|
||||
index?: number;
|
||||
}) {
|
||||
return (
|
||||
<Node $index={index} style={{ left: x, top: y, width }}>
|
||||
<NodeIconContainer>
|
||||
<Icon
|
||||
aria-hidden
|
||||
color={iconColor}
|
||||
size={20}
|
||||
stroke={WORKFLOW_PAGE_TABLER_STROKE}
|
||||
/>
|
||||
</NodeIconContainer>
|
||||
<NodeContent>
|
||||
<NodeLabel>{label}</NodeLabel>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
</NodeContent>
|
||||
{generating ? (
|
||||
<>
|
||||
<SkeletonIcon />
|
||||
<NodeContent>
|
||||
<SkeletonBar $height={8} $width="38%" />
|
||||
<SkeletonBar $height={9} $width="72%" />
|
||||
</NodeContent>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NodeIconContainer className={nodeContentAppear}>
|
||||
<Icon
|
||||
aria-hidden
|
||||
color={iconColor}
|
||||
size={20}
|
||||
stroke={WORKFLOW_PAGE_TABLER_STROKE}
|
||||
/>
|
||||
</NodeIconContainer>
|
||||
<NodeContent>
|
||||
<NodeLabel className={nodeContentAppear}>{label}</NodeLabel>
|
||||
<NodeTitle className={nodeContentAppear}>{title}</NodeTitle>
|
||||
</NodeContent>
|
||||
</>
|
||||
)}
|
||||
</Node>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import { WorkflowNode } from './WorkflowNode';
|
||||
import {
|
||||
workflowBranchLabels as defaultBranchLabels,
|
||||
workflowEdges as defaultEdges,
|
||||
type WorkflowBranchLabel as WorkflowBranchLabelDefinition,
|
||||
type WorkflowEdgeDefinition,
|
||||
type WorkflowNodeDefinition,
|
||||
workflowNodes as defaultNodes,
|
||||
} from './workflow-page-data';
|
||||
import {
|
||||
WORKFLOW_CANVAS_HEIGHT,
|
||||
WORKFLOW_CANVAS_TOP_OFFSET,
|
||||
WORKFLOW_CANVAS_WIDTH,
|
||||
WORKFLOW_NODE_HEIGHT,
|
||||
WORKFLOW_PAGE_COLORS,
|
||||
WORKFLOW_PAGE_FONT,
|
||||
} from './workflow-page-theme';
|
||||
@@ -48,7 +52,7 @@ const CanvasViewport = styled.div`
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
const Canvas = styled.div`
|
||||
const Canvas = styled.div<{ $height: number; $width: number }>`
|
||||
background-color: ${WORKFLOW_PAGE_COLORS.canvasBackground};
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
@@ -58,20 +62,20 @@ const Canvas = styled.div`
|
||||
background-position: 10px 10px;
|
||||
background-size: 20px 20px;
|
||||
box-sizing: border-box;
|
||||
height: ${WORKFLOW_CANVAS_HEIGHT + WORKFLOW_CANVAS_TOP_OFFSET}px;
|
||||
height: ${({ $height }) => `${$height}px`};
|
||||
min-height: 100%;
|
||||
min-width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: ${WORKFLOW_CANVAS_WIDTH}px;
|
||||
width: ${({ $width }) => `${$width}px`};
|
||||
`;
|
||||
|
||||
const CanvasContent = styled.div`
|
||||
height: ${WORKFLOW_CANVAS_HEIGHT}px;
|
||||
left: calc((100% - ${WORKFLOW_CANVAS_WIDTH}px) / 2);
|
||||
const CanvasContent = styled.div<{ $height: number; $width: number }>`
|
||||
height: ${({ $height }) => `${$height}px`};
|
||||
left: calc((100% - ${({ $width }) => `${$width}px`}) / 2);
|
||||
position: absolute;
|
||||
top: ${WORKFLOW_CANVAS_TOP_OFFSET}px;
|
||||
width: ${WORKFLOW_CANVAS_WIDTH}px;
|
||||
width: ${({ $width }) => `${$width}px`};
|
||||
`;
|
||||
|
||||
const ActiveBadge = styled.div`
|
||||
@@ -100,6 +104,8 @@ const PLUS_NODE_SIZE = 24;
|
||||
|
||||
const PlusNodeSquare = styled.div`
|
||||
align-items: center;
|
||||
animation: workflowPlusAppear 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: 820ms;
|
||||
background: ${WORKFLOW_PAGE_COLORS.nodeSurface};
|
||||
border: 1px solid ${WORKFLOW_PAGE_COLORS.nodeBorder};
|
||||
border-radius: 4px;
|
||||
@@ -111,8 +117,141 @@ const PlusNodeSquare = styled.div`
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: ${PLUS_NODE_SIZE}px;
|
||||
|
||||
@keyframes workflowPlusAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type WorkflowLayout = {
|
||||
branchLabels: ReadonlyArray<WorkflowBranchLabelDefinition>;
|
||||
canvasHeight: number;
|
||||
canvasWidth: number;
|
||||
contentHeight: number;
|
||||
contentWidth: number;
|
||||
nodes: ReadonlyArray<WorkflowNodeDefinition>;
|
||||
plusNode?: { x: number; y: number };
|
||||
};
|
||||
|
||||
function getDefaultWorkflowLayout(
|
||||
nodes: ReadonlyArray<WorkflowNodeDefinition>,
|
||||
branchLabels: ReadonlyArray<WorkflowBranchLabelDefinition>,
|
||||
plusNode?: { x: number; y: number },
|
||||
): WorkflowLayout {
|
||||
return {
|
||||
branchLabels,
|
||||
canvasHeight: WORKFLOW_CANVAS_HEIGHT + WORKFLOW_CANVAS_TOP_OFFSET,
|
||||
canvasWidth: WORKFLOW_CANVAS_WIDTH,
|
||||
contentHeight: WORKFLOW_CANVAS_HEIGHT,
|
||||
contentWidth: WORKFLOW_CANVAS_WIDTH,
|
||||
nodes,
|
||||
plusNode,
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomWorkflowLayout(
|
||||
nodes: ReadonlyArray<WorkflowNodeDefinition>,
|
||||
edges: ReadonlyArray<WorkflowEdgeDefinition>,
|
||||
branchLabels: ReadonlyArray<WorkflowBranchLabelDefinition>,
|
||||
plusNode?: { x: number; y: number },
|
||||
): WorkflowLayout {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const labelWidthEstimate = 44;
|
||||
const labelHeightEstimate = 20;
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const node of nodes) {
|
||||
minX = Math.min(minX, node.x);
|
||||
maxX = Math.max(maxX, node.x + node.width);
|
||||
minY = Math.min(minY, node.y);
|
||||
maxY = Math.max(maxY, node.y + WORKFLOW_NODE_HEIGHT);
|
||||
}
|
||||
|
||||
for (const label of branchLabels) {
|
||||
minX = Math.min(minX, label.x);
|
||||
maxX = Math.max(maxX, label.x + labelWidthEstimate);
|
||||
minY = Math.min(minY, label.y);
|
||||
maxY = Math.max(maxY, label.y + labelHeightEstimate);
|
||||
}
|
||||
|
||||
if (plusNode) {
|
||||
minX = Math.min(minX, plusNode.x - PLUS_NODE_SIZE / 2);
|
||||
maxX = Math.max(maxX, plusNode.x + PLUS_NODE_SIZE / 2);
|
||||
minY = Math.min(minY, plusNode.y - PLUS_NODE_SIZE / 2);
|
||||
maxY = Math.max(maxY, plusNode.y + PLUS_NODE_SIZE / 2);
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== 'loopBack') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromNode = nodeById.get(edge.from);
|
||||
const toNode = nodeById.get(edge.to);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rightExtent = fromNode.x + fromNode.width + 40;
|
||||
const bottomExtent = fromNode.y + WORKFLOW_NODE_HEIGHT + 1 + 180;
|
||||
const returnExtent = toNode.y + WORKFLOW_NODE_HEIGHT + 50;
|
||||
|
||||
maxX = Math.max(maxX, rightExtent);
|
||||
maxY = Math.max(maxY, bottomExtent, returnExtent);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
||||
return getDefaultWorkflowLayout(nodes, branchLabels, plusNode);
|
||||
}
|
||||
|
||||
const paddingX = 72;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 56;
|
||||
const offsetX = paddingX - minX;
|
||||
const offsetY = paddingTop - minY;
|
||||
const contentWidth = Math.ceil(maxX - minX + paddingX * 2);
|
||||
const contentHeight = Math.ceil(maxY - minY + paddingTop + paddingBottom);
|
||||
|
||||
return {
|
||||
branchLabels: branchLabels.map((label) => ({
|
||||
...label,
|
||||
x: label.x + offsetX,
|
||||
y: label.y + offsetY,
|
||||
})),
|
||||
canvasHeight: contentHeight + WORKFLOW_CANVAS_TOP_OFFSET,
|
||||
canvasWidth: contentWidth,
|
||||
contentHeight,
|
||||
contentWidth,
|
||||
nodes: nodes.map((node) => ({
|
||||
...node,
|
||||
x: node.x + offsetX,
|
||||
y: node.y + offsetY,
|
||||
})),
|
||||
plusNode: plusNode
|
||||
? {
|
||||
x: plusNode.x + offsetX,
|
||||
y: plusNode.y + offsetY,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
|
||||
const hasCustomNodes = Boolean(page.nodes);
|
||||
const nodes = hasCustomNodes
|
||||
@@ -121,6 +260,9 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
|
||||
const edges = page.edges ?? (hasCustomNodes ? [] : defaultEdges);
|
||||
const branchLabels =
|
||||
page.branchLabels ?? (hasCustomNodes ? [] : defaultBranchLabels);
|
||||
const layout = hasCustomNodes
|
||||
? getCustomWorkflowLayout(nodes, edges, branchLabels, page.plusNode)
|
||||
: getDefaultWorkflowLayout(nodes, branchLabels, page.plusNode);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -131,18 +273,27 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
|
||||
<CanvasViewport
|
||||
aria-label={`Interactive preview of the ${page.header.title.toLowerCase()} workflow`}
|
||||
>
|
||||
<Canvas>
|
||||
<CanvasContent>
|
||||
<WorkflowEdges
|
||||
edges={edges}
|
||||
nodes={nodes}
|
||||
plusNode={page.plusNode}
|
||||
/>
|
||||
<Canvas $height={layout.canvasHeight} $width={layout.canvasWidth}>
|
||||
<CanvasContent
|
||||
$height={layout.contentHeight}
|
||||
$width={layout.contentWidth}
|
||||
data-workflow-canvas
|
||||
>
|
||||
{!page.generating && (
|
||||
<WorkflowEdges
|
||||
canvasHeight={layout.contentHeight}
|
||||
canvasWidth={layout.contentWidth}
|
||||
edges={edges}
|
||||
nodes={layout.nodes}
|
||||
plusNode={layout.plusNode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nodes.map((node, index) => (
|
||||
{layout.nodes.map((node, index) => (
|
||||
<WorkflowNode
|
||||
key={`${node.title}-${node.x}-${node.y}`}
|
||||
Icon={node.Icon}
|
||||
generating={page.generating}
|
||||
iconColor={node.iconColor}
|
||||
index={index}
|
||||
label={node.label}
|
||||
@@ -153,20 +304,21 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
|
||||
/>
|
||||
))}
|
||||
|
||||
{branchLabels.map((label) => (
|
||||
<WorkflowBranchLabel
|
||||
key={`${label.text}-${label.x}-${label.y}`}
|
||||
text={label.text}
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
/>
|
||||
))}
|
||||
{!page.generating &&
|
||||
layout.branchLabels.map((label) => (
|
||||
<WorkflowBranchLabel
|
||||
key={`${label.text}-${label.x}-${label.y}`}
|
||||
text={label.text}
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
/>
|
||||
))}
|
||||
|
||||
{page.plusNode && (
|
||||
{!page.generating && layout.plusNode && (
|
||||
<PlusNodeSquare
|
||||
style={{
|
||||
left: page.plusNode.x - PLUS_NODE_SIZE / 2,
|
||||
top: page.plusNode.y - PLUS_NODE_SIZE / 2,
|
||||
left: layout.plusNode.x - PLUS_NODE_SIZE / 2,
|
||||
top: layout.plusNode.y - PLUS_NODE_SIZE / 2,
|
||||
}}
|
||||
>
|
||||
+
|
||||
|
||||
@@ -20,5 +20,6 @@ export const WORKFLOW_PAGE_COLORS = {
|
||||
nodeSurface: VISUAL_TOKENS.background.secondary,
|
||||
nodeIconSurface: VISUAL_TOKENS.background.transparent.light,
|
||||
textPrimary: VISUAL_TOKENS.font.color.primary,
|
||||
textLight: VISUAL_TOKENS.font.color.light,
|
||||
textTertiary: VISUAL_TOKENS.font.color.tertiary,
|
||||
} as const;
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { VISUAL_TOKENS } from '../utils/app-preview-tokens';
|
||||
|
||||
const LinkPill = styled.span`
|
||||
align-items: center;
|
||||
background-color: ${VISUAL_TOKENS.background.transparent.lighter};
|
||||
border: 1px solid ${VISUAL_TOKENS.border.color.strong};
|
||||
border-radius: ${VISUAL_TOKENS.border.radius.pill};
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
display: inline-flex;
|
||||
font-family: ${VISUAL_TOKENS.font.family};
|
||||
font-size: ${VISUAL_TOKENS.font.size.md};
|
||||
font-weight: ${VISUAL_TOKENS.font.weight.regular};
|
||||
gap: ${VISUAL_TOKENS.spacing[1]};
|
||||
height: 20px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 ${VISUAL_TOKENS.spacing[2]};
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export function PreviewRoundedLink({ label }: { label: string }) {
|
||||
return <LinkPill>{label}</LinkPill>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const SkeletonBase = styled.div`
|
||||
animation: previewSkeletonShimmer 1.4s ease infinite;
|
||||
background: linear-gradient(90deg, #f1f1f1 25%, #fafafa 37%, #f1f1f1 63%);
|
||||
background-size: 400% 100%;
|
||||
|
||||
@keyframes previewSkeletonShimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SkeletonBar = styled(SkeletonBase)<{
|
||||
$height?: number;
|
||||
$radius?: number;
|
||||
$width?: string;
|
||||
}>`
|
||||
border-radius: ${({ $radius = 4 }) => `${$radius}px`};
|
||||
flex-shrink: 0;
|
||||
height: ${({ $height = 10 }) => `${$height}px`};
|
||||
width: ${({ $width = '100%' }) => $width};
|
||||
`;
|
||||
|
||||
export const SkeletonCircle = styled(SkeletonBase)<{ $size: number }>`
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
height: ${({ $size }) => `${$size}px`};
|
||||
width: ${({ $size }) => `${$size}px`};
|
||||
`;
|
||||
|
||||
export const SkeletonBlock = styled(SkeletonBase)<{ $radius?: number }>`
|
||||
border-radius: ${({ $radius = 6 }) => `${$radius}px`};
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { VISUAL_TOKENS } from '../utils/app-preview-tokens';
|
||||
|
||||
type PreviewTagColor =
|
||||
| 'amber'
|
||||
| 'blue'
|
||||
| 'gray'
|
||||
| 'green'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'teal';
|
||||
|
||||
const TAG_COLORS: Record<
|
||||
PreviewTagColor,
|
||||
{ background: string; color: string }
|
||||
> = {
|
||||
amber: { background: '#FEF2A4', color: '#35290F' },
|
||||
blue: { background: '#d9e2fc', color: '#3a5ccc' },
|
||||
gray: { background: '#fafafa', color: '#666666' },
|
||||
green: { background: '#ccebd7', color: '#299764' },
|
||||
orange: { background: '#ffdcc3', color: '#ed5f00' },
|
||||
pink: { background: '#fcdced', color: '#d6409f' },
|
||||
purple: { background: '#eddbf9', color: '#8347b9' },
|
||||
red: { background: '#fdd8d8', color: '#dc3d43' },
|
||||
teal: { background: '#c7ebe5', color: '#0E9888' },
|
||||
};
|
||||
|
||||
const TagPill = styled.span<{
|
||||
$background: string;
|
||||
$color: string;
|
||||
}>`
|
||||
align-items: center;
|
||||
background: ${({ $background }) => $background};
|
||||
border-radius: ${VISUAL_TOKENS.border.radius.sm};
|
||||
color: ${({ $color }) => $color};
|
||||
display: inline-flex;
|
||||
font-family: ${VISUAL_TOKENS.font.family};
|
||||
font-size: ${VISUAL_TOKENS.font.size.md};
|
||||
font-weight: ${VISUAL_TOKENS.font.weight.regular};
|
||||
height: 20px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 ${VISUAL_TOKENS.spacing[2]};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export function PreviewTag({
|
||||
color,
|
||||
label,
|
||||
}: {
|
||||
color?: PreviewTagColor;
|
||||
label: string;
|
||||
}) {
|
||||
const resolvedColor = color ?? 'gray';
|
||||
const palette = TAG_COLORS[resolvedColor];
|
||||
|
||||
return (
|
||||
<TagPill $background={palette.background} $color={palette.color}>
|
||||
{label}
|
||||
</TagPill>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ export const WINDOW_SHADOWS = {
|
||||
resting: '0 10px 64px 0 rgba(0, 0, 0, 0.2)',
|
||||
elevated:
|
||||
'0 14px 80px 0 rgba(0, 0, 0, 0.26), 0 4px 12px 0 rgba(0, 0, 0, 0.08)',
|
||||
floating: '0 36px 64px -8px rgba(0, 0, 0, 0.36)',
|
||||
mobileResting: '0 4px 16px 0 rgba(0, 0, 0, 0.08)',
|
||||
mobileElevated: '0 6px 20px 0 rgba(0, 0, 0, 0.12)',
|
||||
mobileFloating: '0 14px 28px -4px rgba(0, 0, 0, 0.17)',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import type { PageDefinition, SidebarEntry, SidebarItemDef } from '../types';
|
||||
import {
|
||||
AppPreviewSidebar,
|
||||
type DesktopSidebarMode,
|
||||
} from './AppPreviewSidebar';
|
||||
import { AppPreviewNavbar } from './AppPreviewNavbar';
|
||||
import { AppPreviewViewbar } from './AppPreviewViewbar';
|
||||
import { renderPageDefinition } from './PageRenderers';
|
||||
|
||||
const AppLayout = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const RightPane = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 0;
|
||||
padding-right: 8px;
|
||||
padding-top: 12px;
|
||||
row-gap: 12px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
padding-right: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ContentRow = styled.div<{ $hasAside: boolean }>`
|
||||
column-gap: ${({ $hasAside }) => ($hasAside ? '8px' : '0')};
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const IndexSurface = styled.div<{ $compactWorkflowPage: boolean }>`
|
||||
background: ${COLORS.background};
|
||||
border: 1px solid ${COLORS.border};
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
[data-workflow-canvas] {
|
||||
left: ${({ $compactWorkflowPage }) =>
|
||||
$compactWorkflowPage ? '0' : 'auto'};
|
||||
transform: ${({ $compactWorkflowPage }) =>
|
||||
$compactWorkflowPage ? 'scale(0.78) translateX(-6%)' : 'none'};
|
||||
transform-origin: ${({ $compactWorkflowPage }) =>
|
||||
$compactWorkflowPage ? 'top left' : 'center center'};
|
||||
}
|
||||
`;
|
||||
|
||||
const ASIDE_WIDTH = 280;
|
||||
const ASIDE_GAP = 8;
|
||||
|
||||
const AsideSlot = styled.div`
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
// Phone (<600px) AI experience: drop the CRM sidebar/board and let the AI panel
|
||||
// be the whole window.
|
||||
const PanelOnlyRoot = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type AppPreviewLayoutProps = {
|
||||
activeItem?: SidebarItemDef;
|
||||
activeItemId: string;
|
||||
activeItemLabel: string;
|
||||
asideProgress?: number;
|
||||
compactWorkflowPage?: boolean;
|
||||
desktopSidebarMode?: DesktopSidebarMode;
|
||||
favorites: SidebarItemDef[];
|
||||
highlightedItemId?: string;
|
||||
navbarLabel?: string;
|
||||
onSelectPageItem: (itemId: string) => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
openFolderIds: string[];
|
||||
page: PageDefinition;
|
||||
panelOnly?: boolean;
|
||||
revealedObjectIds: string[];
|
||||
rightAside?: ReactNode;
|
||||
workspaceEntries: SidebarEntry[];
|
||||
};
|
||||
|
||||
export function AppPreviewLayout({
|
||||
activeItem,
|
||||
activeItemId,
|
||||
activeItemLabel,
|
||||
asideProgress = 1,
|
||||
compactWorkflowPage = false,
|
||||
desktopSidebarMode = 'expanded',
|
||||
favorites,
|
||||
highlightedItemId,
|
||||
navbarLabel,
|
||||
onSelectPageItem,
|
||||
onToggleFolder,
|
||||
openFolderIds,
|
||||
page,
|
||||
panelOnly = false,
|
||||
revealedObjectIds,
|
||||
rightAside,
|
||||
workspaceEntries,
|
||||
}: AppPreviewLayoutProps) {
|
||||
if (panelOnly && rightAside !== undefined) {
|
||||
return <PanelOnlyRoot>{rightAside}</PanelOnlyRoot>;
|
||||
}
|
||||
|
||||
const activeHeader = page.header;
|
||||
const showViewBar =
|
||||
page.type !== 'dashboard' &&
|
||||
page.type !== 'record' &&
|
||||
page.type !== 'workflow';
|
||||
const resolvedDesktopSidebarMode =
|
||||
page.type === 'record' ? 'collapsed' : desktopSidebarMode;
|
||||
const resolvedNavbarLabel = navbarLabel ?? activeItemLabel;
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppPreviewSidebar
|
||||
desktopSidebarMode={resolvedDesktopSidebarMode}
|
||||
favoritesNav={favorites}
|
||||
highlightedItemId={highlightedItemId}
|
||||
onSelectPageItem={onSelectPageItem}
|
||||
onToggleFolder={onToggleFolder}
|
||||
openFolderIds={openFolderIds}
|
||||
selectedItemId={activeItemId}
|
||||
workspaceNav={workspaceEntries}
|
||||
/>
|
||||
|
||||
<RightPane>
|
||||
<AppPreviewNavbar
|
||||
activeItem={activeItem}
|
||||
activeItemLabel={resolvedNavbarLabel}
|
||||
navbarActions={activeHeader?.navbarActions}
|
||||
revealedObjectIds={revealedObjectIds}
|
||||
/>
|
||||
|
||||
<ContentRow $hasAside={false}>
|
||||
<IndexSurface $compactWorkflowPage={compactWorkflowPage}>
|
||||
{showViewBar ? (
|
||||
<AppPreviewViewbar
|
||||
actions={activeHeader?.actions ?? []}
|
||||
count={activeHeader?.count}
|
||||
pageType={page.type}
|
||||
showListIcon={activeHeader?.showListIcon ?? false}
|
||||
title={activeHeader?.title ?? activeItemLabel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{renderPageDefinition(page, onSelectPageItem)}
|
||||
</IndexSurface>
|
||||
|
||||
{rightAside !== undefined ? (
|
||||
<AsideSlot
|
||||
style={{
|
||||
marginLeft: `${ASIDE_GAP * Math.max(0, Math.min(1, asideProgress))}px`,
|
||||
width: `${ASIDE_WIDTH * Math.max(0, Math.min(1, asideProgress))}px`,
|
||||
}}
|
||||
>
|
||||
{rightAside}
|
||||
</AsideSlot>
|
||||
) : null}
|
||||
</ContentRow>
|
||||
</RightPane>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,15 @@ const BreadcrumbTag = styled.div`
|
||||
padding: 0 2px;
|
||||
`;
|
||||
|
||||
const BreadcrumbIconSlot = styled.span`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 16px;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const CrumbLabel = styled.span`
|
||||
color: ${COLORS.text};
|
||||
font-family: ${APP_FONT};
|
||||
@@ -329,14 +338,14 @@ function renderDefaultActions(
|
||||
|
||||
type AppPreviewNavbarProps = {
|
||||
activeItem?: SidebarItemDef;
|
||||
activeLabel: string;
|
||||
activeItemLabel: string;
|
||||
navbarActions?: NavbarAction[];
|
||||
revealedObjectIds: string[];
|
||||
};
|
||||
|
||||
export function AppPreviewNavbar({
|
||||
activeItem,
|
||||
activeLabel,
|
||||
activeItemLabel,
|
||||
navbarActions,
|
||||
revealedObjectIds,
|
||||
}: AppPreviewNavbarProps) {
|
||||
@@ -344,8 +353,12 @@ export function AppPreviewNavbar({
|
||||
<NavbarBar>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbTag>
|
||||
{activeItem ? renderPreviewIcon(activeItem.icon) : null}
|
||||
<CrumbLabel>{activeLabel}</CrumbLabel>
|
||||
{activeItem ? (
|
||||
<BreadcrumbIconSlot>
|
||||
{renderPreviewIcon(activeItem.icon)}
|
||||
</BreadcrumbIconSlot>
|
||||
) : null}
|
||||
<CrumbLabel>{activeItemLabel}</CrumbLabel>
|
||||
</BreadcrumbTag>
|
||||
</Breadcrumb>
|
||||
|
||||
|
||||
@@ -2,147 +2,178 @@
|
||||
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { SidebarEntry, SidebarItemDef } from '../types';
|
||||
import { SidebarFavorites } from './SidebarFavorites';
|
||||
import { SidebarControls } from './SidebarControls';
|
||||
import { SidebarHeader } from './SidebarHeader';
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
import { isFolder } from './is-folder';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import { buildSidebarIndex } from './build-sidebar-index';
|
||||
import { SidebarRailFavorites } from './SidebarRailFavorites';
|
||||
import { SidebarRailWorkspace } from './SidebarRailWorkspace';
|
||||
import { SidebarWorkspace } from './SidebarWorkspace';
|
||||
|
||||
const SidebarPanel = styled.aside`
|
||||
const SidebarPanel = styled.aside<{ $desktopExpanded: boolean }>`
|
||||
background: transparent;
|
||||
display: grid;
|
||||
flex: 0 0 48px;
|
||||
gap: 8px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
padding: 8px 4px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 8px;
|
||||
row-gap: 8px;
|
||||
width: 48px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
flex-basis: 220px;
|
||||
gap: 12px;
|
||||
padding: 12px 8px;
|
||||
width: 220px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-top: 12px;
|
||||
width: ${({ $desktopExpanded }) => ($desktopExpanded ? '220px' : '48px')};
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarScroll = styled.div`
|
||||
const RailNavigation = styled.div<{ $desktopExpanded: boolean }>`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarSection = styled.div`
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const SidebarSectionLabel = styled.span<{ $workspace?: boolean }>`
|
||||
color: ${COLORS.textLight};
|
||||
display: none;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: ${({ $workspace }) => ($workspace ? '4px 4px 8px' : '0 4px 4px')};
|
||||
row-gap: 8px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'none' : 'flex')};
|
||||
}
|
||||
`;
|
||||
|
||||
const DesktopContent = styled.div<{ $desktopExpanded: boolean }>`
|
||||
display: none;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
row-gap: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const DesktopSections = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
row-gap: 12px;
|
||||
`;
|
||||
|
||||
export type DesktopSidebarMode = 'collapsed' | 'expanded';
|
||||
|
||||
type AppPreviewSidebarProps = {
|
||||
favoritesNav?: SidebarItemDef[];
|
||||
desktopSidebarMode?: DesktopSidebarMode;
|
||||
favoritesNav: SidebarItemDef[];
|
||||
highlightedItemId?: string;
|
||||
onSelectLabel: (label: string) => void;
|
||||
onSelectPageItem: (itemId: string) => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
openFolderIds: string[];
|
||||
selectedLabel: string;
|
||||
workspaceName: string;
|
||||
selectedItemId: string;
|
||||
workspaceNav: SidebarEntry[];
|
||||
};
|
||||
|
||||
export function AppPreviewSidebar({
|
||||
desktopSidebarMode = 'expanded',
|
||||
favoritesNav,
|
||||
highlightedItemId,
|
||||
onSelectLabel,
|
||||
onSelectPageItem,
|
||||
onToggleFolder,
|
||||
openFolderIds,
|
||||
selectedLabel,
|
||||
workspaceName,
|
||||
selectedItemId,
|
||||
workspaceNav,
|
||||
}: AppPreviewSidebarProps) {
|
||||
const renderSidebarEntry = (entry: SidebarEntry): ReactNode => {
|
||||
if (isFolder(entry)) {
|
||||
return (
|
||||
<SidebarItem
|
||||
collapsible
|
||||
expanded={openFolderIds.includes(entry.id)}
|
||||
highlightedItemId={highlightedItemId}
|
||||
key={entry.id}
|
||||
item={{
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
icon: entry.icon,
|
||||
showChevron: entry.showChevron,
|
||||
children: entry.items,
|
||||
}}
|
||||
onSelect={onSelectLabel}
|
||||
onToggleExpanded={() => onToggleFolder(entry.id)}
|
||||
selectedLabel={selectedLabel}
|
||||
/>
|
||||
);
|
||||
const desktopExpanded = desktopSidebarMode === 'expanded';
|
||||
const workspaceIndex = useMemo(
|
||||
() =>
|
||||
buildSidebarIndex({
|
||||
favorites: [],
|
||||
workspace: workspaceNav,
|
||||
}),
|
||||
[workspaceNav],
|
||||
);
|
||||
const collapsedOpenFolderId =
|
||||
workspaceIndex.parentFolderIdsByItemId.get(selectedItemId) ??
|
||||
openFolderIds.at(-1);
|
||||
|
||||
const closeCollapsedFolders = (nextOpenFolderId?: string) => {
|
||||
openFolderIds
|
||||
.filter((folderId) => folderId !== nextOpenFolderId)
|
||||
.forEach((folderId) => onToggleFolder(folderId));
|
||||
};
|
||||
|
||||
const handleCollapsedFavoriteSelect = (itemId: string) => {
|
||||
closeCollapsedFolders();
|
||||
onSelectPageItem(itemId);
|
||||
};
|
||||
|
||||
const handleCollapsedWorkspaceSelect = (
|
||||
itemId: string,
|
||||
folderId?: string,
|
||||
) => {
|
||||
closeCollapsedFolders(folderId);
|
||||
onSelectPageItem(itemId);
|
||||
};
|
||||
|
||||
const handleCollapsedFolderSelect = (
|
||||
folderId: string,
|
||||
firstChildItemId: string,
|
||||
) => {
|
||||
closeCollapsedFolders(folderId);
|
||||
|
||||
if (!openFolderIds.includes(folderId)) {
|
||||
onToggleFolder(folderId);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
highlightedItemId={highlightedItemId}
|
||||
key={entry.id}
|
||||
item={entry}
|
||||
onSelect={onSelectLabel}
|
||||
selectedLabel={selectedLabel}
|
||||
/>
|
||||
);
|
||||
onSelectPageItem(firstChildItemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarPanel>
|
||||
<SidebarHeader workspaceName={workspaceName} />
|
||||
|
||||
<SidebarControls />
|
||||
|
||||
<SidebarScroll>
|
||||
{favoritesNav && favoritesNav.length > 0 ? (
|
||||
<SidebarSection>
|
||||
<SidebarSectionLabel>Favorites</SidebarSectionLabel>
|
||||
{favoritesNav.map((item) => (
|
||||
<SidebarItem
|
||||
highlightedItemId={highlightedItemId}
|
||||
key={item.id}
|
||||
item={item}
|
||||
onSelect={onSelectLabel}
|
||||
selectedLabel={selectedLabel}
|
||||
/>
|
||||
))}
|
||||
</SidebarSection>
|
||||
) : null}
|
||||
<SidebarSection>
|
||||
<SidebarSectionLabel $workspace>Workspace</SidebarSectionLabel>
|
||||
{workspaceNav.map(renderSidebarEntry)}
|
||||
</SidebarSection>
|
||||
</SidebarScroll>
|
||||
<SidebarPanel $desktopExpanded={desktopExpanded}>
|
||||
<SidebarHeader desktopExpanded={desktopExpanded} />
|
||||
<RailNavigation $desktopExpanded={desktopExpanded}>
|
||||
<SidebarControls desktopExpanded={desktopExpanded} />
|
||||
<SidebarRailFavorites
|
||||
favoritesNav={favoritesNav}
|
||||
highlightedItemId={highlightedItemId}
|
||||
onSelectPageItem={handleCollapsedFavoriteSelect}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
<SidebarRailWorkspace
|
||||
collapsedOpenFolderId={collapsedOpenFolderId}
|
||||
highlightedItemId={highlightedItemId}
|
||||
onSelectFolder={handleCollapsedFolderSelect}
|
||||
onSelectPageItem={handleCollapsedWorkspaceSelect}
|
||||
selectedItemId={selectedItemId}
|
||||
workspaceNav={workspaceNav}
|
||||
/>
|
||||
</RailNavigation>
|
||||
<DesktopContent $desktopExpanded={desktopExpanded}>
|
||||
<SidebarControls desktopExpanded={desktopExpanded} />
|
||||
<DesktopSections>
|
||||
<SidebarFavorites
|
||||
favoritesNav={favoritesNav}
|
||||
highlightedItemId={highlightedItemId}
|
||||
onSelectPageItem={onSelectPageItem}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
<SidebarWorkspace
|
||||
highlightedItemId={highlightedItemId}
|
||||
onSelectPageItem={onSelectPageItem}
|
||||
onToggleFolder={onToggleFolder}
|
||||
openFolderIds={openFolderIds}
|
||||
selectedItemId={selectedItemId}
|
||||
workspaceNav={workspaceNav}
|
||||
/>
|
||||
</DesktopSections>
|
||||
</DesktopContent>
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,14 @@ const ViewbarBar = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 8px 8px 8px 12px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
padding-right: 8px;
|
||||
padding-top: 8px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ViewSwitcher = styled.div`
|
||||
const ViewSwitcher = styled.div<{ $tableAligned?: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -31,7 +34,10 @@ const ViewSwitcher = styled.div`
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 4px;
|
||||
padding-top: 0;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 0;
|
||||
padding-left: ${({ $tableAligned }) => ($tableAligned ? '0' : '4px')};
|
||||
`;
|
||||
|
||||
const ViewName = styled.span`
|
||||
@@ -103,10 +109,11 @@ export function AppPreviewViewbar({
|
||||
title,
|
||||
}: AppPreviewViewbarProps) {
|
||||
const showPageCount = count !== undefined;
|
||||
const isTableAligned = pageType === 'table' && showListIcon;
|
||||
|
||||
return (
|
||||
<ViewbarBar>
|
||||
<ViewSwitcher aria-hidden="true">
|
||||
<ViewSwitcher $tableAligned={isTableAligned} aria-hidden="true">
|
||||
{showListIcon ? (
|
||||
<>
|
||||
{pageType === 'kanban' ? (
|
||||
|
||||
@@ -2,17 +2,8 @@
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
DashboardPageDefinition,
|
||||
KanbanPageDefinition,
|
||||
PageDefinition,
|
||||
PageType,
|
||||
RecordPageDefinition,
|
||||
TablePageDefinition,
|
||||
WorkflowPageDefinition,
|
||||
} from '../types';
|
||||
import type { PageDefinition } from '../types';
|
||||
import { KanbanPage } from '../Pages/Kanban/KanbanPage';
|
||||
import { RecordPage } from '../Pages/Record/RecordPage';
|
||||
import { TablePage } from '../Pages/Table/TablePage';
|
||||
@@ -48,43 +39,31 @@ const SalesDashboardPage = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
const PAGE_RENDERERS = {
|
||||
table: (page: TablePageDefinition) => <TablePage page={page} />,
|
||||
kanban: (page: KanbanPageDefinition) => <KanbanPage page={page} />,
|
||||
dashboard: (page: DashboardPageDefinition) => (
|
||||
<DashboardViewport
|
||||
aria-label={`Interactive preview of the ${page.header.title.toLowerCase()}`}
|
||||
>
|
||||
<SalesDashboardPage page={page} />
|
||||
</DashboardViewport>
|
||||
),
|
||||
record: (page: RecordPageDefinition) => <RecordPage page={page} />,
|
||||
workflow: (page: WorkflowPageDefinition) => <WorkflowPage page={page} />,
|
||||
} satisfies {
|
||||
[K in PageType]: (page: Extract<PageDefinition, { type: K }>) => ReactNode;
|
||||
};
|
||||
|
||||
export function renderPageDefinition(
|
||||
page: PageDefinition,
|
||||
onNavigateToLabel?: (label: string) => void,
|
||||
pageKey?: string,
|
||||
onNavigateToPageItemId?: (itemId: string) => void,
|
||||
) {
|
||||
switch (page.type) {
|
||||
case 'table':
|
||||
return (
|
||||
<TablePage
|
||||
key={pageKey}
|
||||
page={page}
|
||||
onNavigateToLabel={onNavigateToLabel}
|
||||
onNavigateToPageItemId={onNavigateToPageItemId}
|
||||
/>
|
||||
);
|
||||
case 'kanban':
|
||||
return PAGE_RENDERERS.kanban(page);
|
||||
return <KanbanPage page={page} />;
|
||||
case 'dashboard':
|
||||
return PAGE_RENDERERS.dashboard(page);
|
||||
return (
|
||||
<DashboardViewport
|
||||
aria-label={`Interactive preview of the ${page.header.title.toLowerCase()}`}
|
||||
>
|
||||
<SalesDashboardPage page={page} />
|
||||
</DashboardViewport>
|
||||
);
|
||||
case 'record':
|
||||
return PAGE_RENDERERS.record(page);
|
||||
return <RecordPage page={page} />;
|
||||
case 'workflow':
|
||||
return PAGE_RENDERERS.workflow(page);
|
||||
return <WorkflowPage page={page} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,152 +3,118 @@
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
IconHome2,
|
||||
IconMessageCircle,
|
||||
IconHome,
|
||||
IconMessageCircle2,
|
||||
IconMessageCirclePlus,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
APP_FONT,
|
||||
COLORS,
|
||||
TABLER_STROKE,
|
||||
} from '../Shared/utils/app-preview-theme';
|
||||
import { APP_FONT } from '../Shared/utils/app-preview-theme';
|
||||
|
||||
const SidebarControlsRoot = styled.div`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: auto;
|
||||
const Root = styled.div<{ $desktopExpanded: boolean }>`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
grid-auto-flow: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? 'space-between' : 'center'};
|
||||
}
|
||||
`;
|
||||
|
||||
const SegmentedRail = styled.div`
|
||||
background: #fcfcfccc;
|
||||
border: 1px solid ${COLORS.border};
|
||||
border-radius: 40px;
|
||||
const SegmentedRail = styled.div<{ $desktopExpanded: boolean }>`
|
||||
display: none;
|
||||
gap: 2px;
|
||||
grid-auto-flow: column;
|
||||
padding: 3px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: grid;
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
|
||||
align-items: center;
|
||||
background-color: #fcfcfccc;
|
||||
border: 1px solid #ebebeb;
|
||||
border-radius: 999px;
|
||||
column-gap: 2px;
|
||||
height: 26px;
|
||||
padding-bottom: 3px;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Segment = styled.div<{ $selected?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ $selected }) => ($selected ? '#0000000a' : 'transparent')};
|
||||
border-radius: 16px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
padding: 0 8px;
|
||||
width: 32px;
|
||||
}
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const NewChat = styled.div`
|
||||
const NewChat = styled.div<{ $desktopExpanded: boolean }>`
|
||||
align-items: center;
|
||||
background: ${COLORS.backgroundSecondary};
|
||||
border: 1px solid ${COLORS.border};
|
||||
border-radius: 40px;
|
||||
color: ${COLORS.textSecondary};
|
||||
background-color: #fcfcfc;
|
||||
border: 1px solid #ebebeb;
|
||||
border-radius: 999px;
|
||||
color: #666666;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 3px;
|
||||
width: 28px;
|
||||
width: 32px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
width: 103px;
|
||||
column-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '4px' : '0')};
|
||||
height: ${({ $desktopExpanded }) => ($desktopExpanded ? '26px' : '32px')};
|
||||
padding-bottom: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? '3px' : '0'};
|
||||
padding-left: ${({ $desktopExpanded }) => ($desktopExpanded ? '3px' : '0')};
|
||||
padding-right: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? '3px' : '0'};
|
||||
padding-top: ${({ $desktopExpanded }) => ($desktopExpanded ? '3px' : '0')};
|
||||
width: ${({ $desktopExpanded }) => ($desktopExpanded ? '103px' : '32px')};
|
||||
}
|
||||
`;
|
||||
|
||||
const NewChatLabel = styled.span`
|
||||
const NewChatLabel = styled.span<{ $desktopExpanded: boolean }>`
|
||||
display: none;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'block' : 'none')};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
}
|
||||
`;
|
||||
|
||||
type MiniIconProps = {
|
||||
color?: string;
|
||||
size?: number;
|
||||
type SidebarControlsProps = {
|
||||
desktopExpanded: boolean;
|
||||
};
|
||||
|
||||
function HomeMini({ color = COLORS.textSecondary, size = 16 }: MiniIconProps) {
|
||||
export function SidebarControls({ desktopExpanded }: SidebarControlsProps) {
|
||||
return (
|
||||
<IconHome2 aria-hidden color={color} size={size} stroke={TABLER_STROKE} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommentMini({
|
||||
color = COLORS.textTertiary,
|
||||
size = 16,
|
||||
}: MiniIconProps) {
|
||||
return (
|
||||
<IconMessageCircle
|
||||
aria-hidden
|
||||
color={color}
|
||||
size={size}
|
||||
stroke={TABLER_STROKE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCirclePlusMini({
|
||||
color = COLORS.textSecondary,
|
||||
size = 16,
|
||||
}: MiniIconProps) {
|
||||
return (
|
||||
<IconMessageCirclePlus
|
||||
aria-hidden
|
||||
color={color}
|
||||
size={size}
|
||||
stroke={TABLER_STROKE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarControls() {
|
||||
return (
|
||||
<SidebarControlsRoot>
|
||||
<SegmentedRail aria-hidden="true">
|
||||
<Root $desktopExpanded={desktopExpanded}>
|
||||
<SegmentedRail $desktopExpanded={desktopExpanded} aria-hidden="true">
|
||||
<Segment $selected>
|
||||
<HomeMini />
|
||||
<IconHome aria-hidden color="#333333" size={16} strokeWidth={2} />
|
||||
</Segment>
|
||||
<Segment>
|
||||
<CommentMini />
|
||||
<IconMessageCircle2
|
||||
aria-hidden
|
||||
color="#999999"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Segment>
|
||||
</SegmentedRail>
|
||||
<NewChat aria-hidden="true">
|
||||
<MessageCirclePlusMini />
|
||||
<NewChatLabel>New chat</NewChatLabel>
|
||||
|
||||
<NewChat $desktopExpanded={desktopExpanded} aria-hidden="true">
|
||||
<IconMessageCirclePlus
|
||||
aria-hidden
|
||||
color="#666666"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<NewChatLabel $desktopExpanded={desktopExpanded}>New chat</NewChatLabel>
|
||||
</NewChat>
|
||||
</SidebarControlsRoot>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
|
||||
import type { SidebarFolderDef } from '../types';
|
||||
import { MiniIcon } from '../Shared/components/MiniIcon';
|
||||
import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import {
|
||||
DesktopBranchLine,
|
||||
DesktopChildStack,
|
||||
SidebarDesktopItem,
|
||||
} from './SidebarDesktopItem';
|
||||
|
||||
const SIDEBAR_ACTIVE_BACKGROUND = 'rgba(0, 0, 0, 0.04)';
|
||||
|
||||
const FolderButton = styled.button<{ $expanded?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ $expanded }) =>
|
||||
$expanded ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 0;
|
||||
padding-left: 4px;
|
||||
padding-right: 2px;
|
||||
padding-top: 0;
|
||||
transition: background-color 0.14s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: ${SIDEBAR_ACTIVE_BACKGROUND};
|
||||
}
|
||||
`;
|
||||
|
||||
const FolderRowMain = styled.div`
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const FolderText = styled.span`
|
||||
color: ${COLORS.textSecondary};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const FolderChevron = styled.div<{ $expanded?: boolean }>`
|
||||
color: ${COLORS.textTertiary};
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')});
|
||||
transition: transform 0.16s ease;
|
||||
`;
|
||||
|
||||
type SidebarDesktopFolderProps = {
|
||||
expanded: boolean;
|
||||
folder: SidebarFolderDef;
|
||||
highlightedItemId?: string;
|
||||
onSelectItem?: (itemId: string) => void;
|
||||
onToggleExpanded: () => void;
|
||||
selectedItemId?: string;
|
||||
};
|
||||
|
||||
export function SidebarDesktopFolder({
|
||||
expanded,
|
||||
folder,
|
||||
highlightedItemId,
|
||||
onSelectItem,
|
||||
onToggleExpanded,
|
||||
selectedItemId,
|
||||
}: SidebarDesktopFolderProps) {
|
||||
const hasActiveChild = folder.items.some(
|
||||
(item) => item.id === selectedItemId,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FolderButton
|
||||
$expanded={expanded || hasActiveChild}
|
||||
onClick={onToggleExpanded}
|
||||
type="button"
|
||||
>
|
||||
<FolderRowMain>
|
||||
{renderPreviewIcon(folder.icon)}
|
||||
<FolderText>{folder.label}</FolderText>
|
||||
</FolderRowMain>
|
||||
<FolderChevron $expanded={expanded}>
|
||||
<MiniIcon
|
||||
color={COLORS.textTertiary}
|
||||
icon={IconChevronDown}
|
||||
size={12}
|
||||
/>
|
||||
</FolderChevron>
|
||||
</FolderButton>
|
||||
{expanded ? (
|
||||
<DesktopChildStack>
|
||||
<DesktopBranchLine />
|
||||
{folder.items.map((item, index) => (
|
||||
<SidebarDesktopItem
|
||||
depth={1}
|
||||
highlightedItemId={highlightedItemId}
|
||||
isLastChild={index === folder.items.length - 1}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelect={onSelectItem}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
))}
|
||||
</DesktopChildStack>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarItemDef } from '../types';
|
||||
import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import { getSidebarIconToneRgb } from '../Shared/utils/get-sidebar-icon-tone-rgb';
|
||||
|
||||
const SIDEBAR_ACTIVE_BACKGROUND = 'rgba(0, 0, 0, 0.04)';
|
||||
|
||||
const DesktopItemButton = styled.button<{
|
||||
$active?: boolean;
|
||||
$depth?: number;
|
||||
$highlightRgb?: string;
|
||||
$highlighted?: boolean;
|
||||
}>`
|
||||
--highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
|
||||
align-items: center;
|
||||
animation: ${({ $highlighted }) =>
|
||||
$highlighted
|
||||
? 'sidebarDesktopItemAppear 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
|
||||
: 'none'};
|
||||
appearance: none;
|
||||
background: ${({ $active }) =>
|
||||
$active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 0;
|
||||
padding-left: ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
|
||||
padding-right: 2px;
|
||||
padding-top: 0;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
transition: background-color 0.14s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: ${SIDEBAR_ACTIVE_BACKGROUND};
|
||||
}
|
||||
|
||||
@keyframes sidebarDesktopItemAppear {
|
||||
0% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
box-shadow:
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
opacity: 0;
|
||||
transform: translateX(-32px) translateY(-6px) scale(0.6);
|
||||
}
|
||||
16% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
|
||||
box-shadow:
|
||||
0 0 0 6px rgba(var(--highlight-rgb, 237, 95, 0), 0.4),
|
||||
0 12px 28px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1.18);
|
||||
}
|
||||
32% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.42);
|
||||
box-shadow:
|
||||
0 0 0 12px rgba(var(--highlight-rgb, 237, 95, 0), 0.24),
|
||||
0 10px 22px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.38);
|
||||
transform: translateX(0) scale(0.97);
|
||||
}
|
||||
50% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.28);
|
||||
box-shadow:
|
||||
0 0 0 18px rgba(var(--highlight-rgb, 237, 95, 0), 0.12),
|
||||
0 6px 16px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.22);
|
||||
transform: translateX(0) scale(1.02);
|
||||
}
|
||||
72% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.16);
|
||||
box-shadow:
|
||||
0 0 0 22px rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
background: ${SIDEBAR_ACTIVE_BACKGROUND};
|
||||
box-shadow:
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DesktopItemLink = styled.a<{ $active?: boolean; $depth?: number }>`
|
||||
align-items: center;
|
||||
background: ${({ $active }) =>
|
||||
$active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 0;
|
||||
padding-left: ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
|
||||
padding-right: 2px;
|
||||
padding-top: 0;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.14s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: ${SIDEBAR_ACTIVE_BACKGROUND};
|
||||
}
|
||||
`;
|
||||
|
||||
export const DesktopBranchCell = styled.div<{ $isLastChild?: boolean }>`
|
||||
align-self: stretch;
|
||||
flex: 0 0 9px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
background: ${COLORS.borderStrong};
|
||||
content: '';
|
||||
inset: 0 8px 0 0;
|
||||
opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)};
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-bottom: 1px solid ${COLORS.borderStrong};
|
||||
border-left: 1px solid ${COLORS.borderStrong};
|
||||
border-radius: 0 0 0 4px;
|
||||
content: '';
|
||||
inset: 0 0 12px 0;
|
||||
position: absolute;
|
||||
}
|
||||
`;
|
||||
|
||||
const DesktopRowMain = styled.div<{ $withBranch?: boolean }>`
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')};
|
||||
`;
|
||||
|
||||
const DesktopItemText = styled.div`
|
||||
align-items: center;
|
||||
column-gap: 2px;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const DesktopItemLabel = styled.span<{ $active?: boolean }>`
|
||||
color: ${({ $active }) => ($active ? COLORS.text : COLORS.textSecondary)};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const DesktopItemMeta = styled.span`
|
||||
color: ${COLORS.textLight};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
type SidebarDesktopItemProps = {
|
||||
depth?: number;
|
||||
highlightedItemId?: string;
|
||||
isLastChild?: boolean;
|
||||
item: SidebarItemDef;
|
||||
onSelect?: (itemId: string) => void;
|
||||
selectedItemId?: string;
|
||||
};
|
||||
|
||||
export function SidebarDesktopItem({
|
||||
depth = 0,
|
||||
highlightedItemId,
|
||||
isLastChild = false,
|
||||
item,
|
||||
onSelect,
|
||||
selectedItemId,
|
||||
}: SidebarDesktopItemProps) {
|
||||
const showBranch = depth > 0;
|
||||
const rowActive = selectedItemId !== undefined && item.id === selectedItemId;
|
||||
const rowHighlighted = highlightedItemId === item.id;
|
||||
const highlightRgb = getSidebarIconToneRgb(item.icon);
|
||||
|
||||
const rowContent = (
|
||||
<>
|
||||
{showBranch ? <DesktopBranchCell $isLastChild={isLastChild} /> : null}
|
||||
<DesktopRowMain $withBranch={showBranch}>
|
||||
{renderPreviewIcon(item.icon, rowHighlighted)}
|
||||
<DesktopItemText>
|
||||
<DesktopItemLabel $active={rowActive}>{item.label}</DesktopItemLabel>
|
||||
{item.meta ? <DesktopItemMeta>· {item.meta}</DesktopItemMeta> : null}
|
||||
</DesktopItemText>
|
||||
</DesktopRowMain>
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<DesktopItemLink
|
||||
$active={rowActive}
|
||||
$depth={depth}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{rowContent}
|
||||
</DesktopItemLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopItemButton
|
||||
$active={rowActive}
|
||||
$depth={depth}
|
||||
$highlightRgb={rowHighlighted ? highlightRgb : undefined}
|
||||
$highlighted={rowHighlighted}
|
||||
onClick={onSelect ? () => onSelect(item.id) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{rowContent}
|
||||
</DesktopItemButton>
|
||||
);
|
||||
}
|
||||
|
||||
export const DesktopBranchLine = styled.div`
|
||||
background: ${COLORS.borderStrong};
|
||||
bottom: 14px;
|
||||
left: 11px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
export const DesktopChildStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 2px;
|
||||
position: relative;
|
||||
`;
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarItemDef } from '../types';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import { SidebarDesktopItem } from './SidebarDesktopItem';
|
||||
|
||||
const FavoritesSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
row-gap: 2px;
|
||||
`;
|
||||
|
||||
const FavoritesLabelRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
padding-left: 4px;
|
||||
padding-right: 2px;
|
||||
`;
|
||||
|
||||
const FavoritesLabel = styled.span`
|
||||
color: ${COLORS.textLight};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
type SidebarFavoritesProps = {
|
||||
favoritesNav: SidebarItemDef[];
|
||||
highlightedItemId?: string;
|
||||
onSelectPageItem: (itemId: string) => void;
|
||||
selectedItemId: string;
|
||||
};
|
||||
|
||||
export function SidebarFavorites({
|
||||
favoritesNav,
|
||||
highlightedItemId,
|
||||
onSelectPageItem,
|
||||
selectedItemId,
|
||||
}: SidebarFavoritesProps) {
|
||||
return (
|
||||
<FavoritesSection>
|
||||
<FavoritesLabelRow>
|
||||
<FavoritesLabel>Favorites</FavoritesLabel>
|
||||
</FavoritesLabelRow>
|
||||
{favoritesNav.map((item) => (
|
||||
<SidebarDesktopItem
|
||||
highlightedItemId={highlightedItemId}
|
||||
key={item.id}
|
||||
item={item}
|
||||
onSelect={onSelectPageItem}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
))}
|
||||
</FavoritesSection>
|
||||
);
|
||||
}
|
||||
@@ -8,133 +8,116 @@ import {
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { MiniIcon } from '../Shared/components/MiniIcon';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import { APP_FONT } from '../Shared/utils/app-preview-theme';
|
||||
|
||||
const APPLE_WORKSPACE_LOGO_SRC = '/images/home/hero/apple-rainbow-logo.svg';
|
||||
|
||||
const SidebarTopBar = styled.div`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 32px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkspaceMenu = styled.div`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: auto;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 6px 4px;
|
||||
|
||||
> svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
gap: 8px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
justify-content: stretch;
|
||||
|
||||
> svg:last-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkspaceIcon = styled.div`
|
||||
const Header = styled.div<{ $desktopExpanded: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 8px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
flex-direction: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? 'row' : 'column'};
|
||||
justify-content: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? 'space-between' : 'flex-start'};
|
||||
row-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '0' : '16px')};
|
||||
}
|
||||
`;
|
||||
|
||||
const LeftGroup = styled.div<{ $desktopExpanded: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
column-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '8px' : '0')};
|
||||
justify-content: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? 'flex-start' : 'center'};
|
||||
width: ${({ $desktopExpanded }) => ($desktopExpanded ? 'auto' : '100%')};
|
||||
}
|
||||
`;
|
||||
|
||||
const Logo = styled.img`
|
||||
display: block;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const WorkspaceIconImage = styled.img`
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const WorkspaceLabel = styled.span`
|
||||
color: ${COLORS.text};
|
||||
const Name = styled.span<{ $desktopExpanded: boolean }>`
|
||||
display: none;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'block' : 'none')};
|
||||
color: #333333;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarTopActions = styled.div`
|
||||
align-items: center;
|
||||
const Chevron = styled.span<{ $desktopExpanded: boolean }>`
|
||||
display: none;
|
||||
gap: 2px;
|
||||
grid-auto-flow: column;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: grid;
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarIconButton = styled.div`
|
||||
const RightGroup = styled.div<{ $desktopExpanded: boolean }>`
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
column-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '8px' : '0')};
|
||||
flex-direction: ${({ $desktopExpanded }) =>
|
||||
$desktopExpanded ? 'row' : 'column'};
|
||||
}
|
||||
`;
|
||||
|
||||
const CollapseButton = styled.span<{ $desktopExpanded: boolean }>`
|
||||
display: none;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
|
||||
}
|
||||
`;
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
workspaceName: string;
|
||||
desktopExpanded: boolean;
|
||||
};
|
||||
|
||||
export function SidebarHeader({ workspaceName }: SidebarHeaderProps) {
|
||||
export function SidebarHeader({ desktopExpanded }: SidebarHeaderProps) {
|
||||
return (
|
||||
<SidebarTopBar>
|
||||
<WorkspaceMenu>
|
||||
<WorkspaceIcon>
|
||||
<WorkspaceIconImage
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
src={APPLE_WORKSPACE_LOGO_SRC}
|
||||
/>
|
||||
</WorkspaceIcon>
|
||||
<WorkspaceLabel>{workspaceName}</WorkspaceLabel>
|
||||
<MiniIcon icon={IconChevronDown} color={COLORS.textLight} size={12} />
|
||||
</WorkspaceMenu>
|
||||
<SidebarTopActions>
|
||||
<SidebarIconButton aria-hidden="true">
|
||||
<MiniIcon icon={IconSearch} color={COLORS.textTertiary} size={16} />
|
||||
</SidebarIconButton>
|
||||
<SidebarIconButton aria-hidden="true">
|
||||
<MiniIcon
|
||||
icon={IconLayoutSidebarLeftCollapse}
|
||||
color={COLORS.textTertiary}
|
||||
size={16}
|
||||
/>
|
||||
</SidebarIconButton>
|
||||
</SidebarTopActions>
|
||||
</SidebarTopBar>
|
||||
<Header $desktopExpanded={desktopExpanded}>
|
||||
<LeftGroup $desktopExpanded={desktopExpanded}>
|
||||
<Logo
|
||||
alt="Workspace logo"
|
||||
aria-hidden="true"
|
||||
src={APPLE_WORKSPACE_LOGO_SRC}
|
||||
/>
|
||||
<Name $desktopExpanded={desktopExpanded}>Apple</Name>
|
||||
<Chevron $desktopExpanded={desktopExpanded}>
|
||||
<IconChevronDown color="#CCCCCC" size={16} />
|
||||
</Chevron>
|
||||
</LeftGroup>
|
||||
<RightGroup $desktopExpanded={desktopExpanded}>
|
||||
<IconSearch color="#666666" size={16} />
|
||||
<CollapseButton $desktopExpanded={desktopExpanded}>
|
||||
<IconLayoutSidebarLeftCollapse color="#666666" size={16} />
|
||||
</CollapseButton>
|
||||
</RightGroup>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
|
||||
import type { SidebarItemDef } from '../types';
|
||||
import { getSidebarIconToneRgb } from '../Shared/utils/get-sidebar-icon-tone-rgb';
|
||||
import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
|
||||
import { MiniIcon } from '../Shared/components/MiniIcon';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import { VISUAL_TOKENS } from '../Shared/utils/app-preview-tokens';
|
||||
|
||||
const SidebarItemRow = styled.div<{
|
||||
$active?: boolean;
|
||||
$depth?: number;
|
||||
$interactive?: boolean;
|
||||
$withBranch?: boolean;
|
||||
$highlighted?: boolean;
|
||||
$highlightRgb?: string;
|
||||
}>`
|
||||
--highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
|
||||
align-items: center;
|
||||
background: ${({ $active }) =>
|
||||
$active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'};
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
grid-template-columns: auto;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.14s ease;
|
||||
animation: ${({ $highlighted }) =>
|
||||
$highlighted
|
||||
? 'objectAppearRow 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
|
||||
: 'none'};
|
||||
transform-origin: left center;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-template-columns: ${({ $withBranch }) =>
|
||||
$withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
|
||||
justify-content: stretch;
|
||||
padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${({ $active, $interactive }) =>
|
||||
$active || $interactive
|
||||
? VISUAL_TOKENS.background.transparent.medium
|
||||
: 'transparent'};
|
||||
}
|
||||
|
||||
@keyframes objectAppearRow {
|
||||
0% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
box-shadow:
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
opacity: 0;
|
||||
transform: translateX(-32px) translateY(-6px) scale(0.6);
|
||||
}
|
||||
16% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
|
||||
box-shadow:
|
||||
0 0 0 6px rgba(var(--highlight-rgb, 237, 95, 0), 0.4),
|
||||
0 12px 28px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1.18);
|
||||
}
|
||||
32% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.42);
|
||||
box-shadow:
|
||||
0 0 0 12px rgba(var(--highlight-rgb, 237, 95, 0), 0.24),
|
||||
0 10px 22px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.38);
|
||||
transform: translateX(0) scale(0.97);
|
||||
}
|
||||
50% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.28);
|
||||
box-shadow:
|
||||
0 0 0 18px rgba(var(--highlight-rgb, 237, 95, 0), 0.12),
|
||||
0 6px 16px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.22);
|
||||
transform: translateX(0) scale(1.02);
|
||||
}
|
||||
72% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.16);
|
||||
box-shadow:
|
||||
0 0 0 22px rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
background: ${VISUAL_TOKENS.background.transparent.medium};
|
||||
box-shadow:
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarItemRowLink = styled.a<{
|
||||
$active?: boolean;
|
||||
$depth?: number;
|
||||
$interactive?: boolean;
|
||||
$withBranch?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
background: ${({ $active }) =>
|
||||
$active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'};
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
grid-template-columns: auto;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.14s ease;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-template-columns: ${({ $withBranch }) =>
|
||||
$withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
|
||||
justify-content: stretch;
|
||||
padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${({ $active, $interactive }) =>
|
||||
$active || $interactive
|
||||
? VISUAL_TOKENS.background.transparent.medium
|
||||
: 'transparent'};
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarItemText = styled.div`
|
||||
display: none;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarItemLabel = styled.span<{ $active?: boolean }>`
|
||||
color: ${({ $active }) => ($active ? COLORS.text : COLORS.textSecondary)};
|
||||
display: none;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarItemMeta = styled.span`
|
||||
color: ${COLORS.textLight};
|
||||
display: none;
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 13px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarChevron = styled.div<{ $expanded?: boolean }>`
|
||||
color: ${COLORS.textTertiary};
|
||||
display: none;
|
||||
transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')});
|
||||
transition: transform 0.16s ease;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarChildStack = styled.div`
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const BranchLine = styled.div`
|
||||
background: ${COLORS.borderStrong};
|
||||
bottom: 14px;
|
||||
left: 11px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
const SidebarBranchCell = styled.div<{ $isLastChild?: boolean }>`
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
width: 9px;
|
||||
|
||||
&::before {
|
||||
background: ${COLORS.borderStrong};
|
||||
content: '';
|
||||
inset: 0 88.89% 0 0;
|
||||
opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)};
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-bottom: 1px solid ${COLORS.borderStrong};
|
||||
border-left: 1px solid ${COLORS.borderStrong};
|
||||
border-radius: 0 0 0 4px;
|
||||
content: '';
|
||||
inset: 0 0 45.83% 0;
|
||||
position: absolute;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarRowMain = styled.div<{ $withBranch?: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')};
|
||||
`;
|
||||
|
||||
type SidebarItemProps = {
|
||||
collapsible?: boolean;
|
||||
expanded?: boolean;
|
||||
depth?: number;
|
||||
highlightedItemId?: string;
|
||||
interactive?: boolean;
|
||||
isLastChild?: boolean;
|
||||
item: SidebarItemDef;
|
||||
onSelect?: (label: string) => void;
|
||||
onToggleExpanded?: () => void;
|
||||
selectedLabel?: string;
|
||||
};
|
||||
|
||||
export function SidebarItem({
|
||||
collapsible = false,
|
||||
expanded = false,
|
||||
depth = 0,
|
||||
highlightedItemId,
|
||||
interactive = true,
|
||||
isLastChild = false,
|
||||
item,
|
||||
onSelect,
|
||||
onToggleExpanded,
|
||||
selectedLabel,
|
||||
}: SidebarItemProps) {
|
||||
const showBranch = depth > 0;
|
||||
const rowSelectable = interactive && item.href === undefined && !collapsible;
|
||||
const rowInteractive =
|
||||
rowSelectable || item.href !== undefined || (interactive && collapsible);
|
||||
const rowActive =
|
||||
rowSelectable &&
|
||||
selectedLabel !== undefined &&
|
||||
item.label === selectedLabel;
|
||||
const rowHighlighted = highlightedItemId === item.id;
|
||||
const childItems = item.children ?? [];
|
||||
const highlightRgb = getSidebarIconToneRgb(item.icon);
|
||||
const rowContent = (
|
||||
<>
|
||||
{showBranch ? <SidebarBranchCell $isLastChild={isLastChild} /> : null}
|
||||
<SidebarRowMain $withBranch={showBranch}>
|
||||
{renderPreviewIcon(item.icon, rowHighlighted)}
|
||||
<SidebarItemText>
|
||||
<SidebarItemLabel $active={rowActive}>{item.label}</SidebarItemLabel>
|
||||
{item.meta ? <SidebarItemMeta>· {item.meta}</SidebarItemMeta> : null}
|
||||
</SidebarItemText>
|
||||
</SidebarRowMain>
|
||||
{item.showChevron || (item.children && item.children.length > 0) ? (
|
||||
<SidebarChevron $expanded={!collapsible || expanded}>
|
||||
<MiniIcon
|
||||
icon={IconChevronDown}
|
||||
color={COLORS.textTertiary}
|
||||
size={12}
|
||||
/>
|
||||
</SidebarChevron>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.href ? (
|
||||
<SidebarItemRowLink
|
||||
$active={rowActive}
|
||||
$depth={depth}
|
||||
$interactive={rowInteractive}
|
||||
$withBranch={showBranch}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
style={{ cursor: rowInteractive ? 'pointer' : 'default' }}
|
||||
target="_blank"
|
||||
>
|
||||
{rowContent}
|
||||
</SidebarItemRowLink>
|
||||
) : (
|
||||
<SidebarItemRow
|
||||
$active={rowActive}
|
||||
$depth={depth}
|
||||
$highlightRgb={rowHighlighted ? highlightRgb : undefined}
|
||||
$highlighted={rowHighlighted}
|
||||
$interactive={rowInteractive}
|
||||
$withBranch={showBranch}
|
||||
onClick={
|
||||
collapsible
|
||||
? onToggleExpanded
|
||||
: rowSelectable
|
||||
? () => onSelect?.(item.label)
|
||||
: undefined
|
||||
}
|
||||
style={{ cursor: rowInteractive ? 'pointer' : 'default' }}
|
||||
>
|
||||
{rowContent}
|
||||
</SidebarItemRow>
|
||||
)}
|
||||
{childItems.length > 0 && (!collapsible || expanded) ? (
|
||||
<SidebarChildStack>
|
||||
<BranchLine />
|
||||
{childItems.map((child, index) => (
|
||||
<SidebarItem
|
||||
key={child.id}
|
||||
depth={depth + 1}
|
||||
highlightedItemId={highlightedItemId}
|
||||
isLastChild={index === childItems.length - 1}
|
||||
interactive={interactive}
|
||||
item={child}
|
||||
onSelect={onSelect}
|
||||
selectedLabel={selectedLabel}
|
||||
/>
|
||||
))}
|
||||
</SidebarChildStack>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarItemDef } from '../types';
|
||||
import { SidebarRailItem } from './SidebarRailItem';
|
||||
|
||||
const FavoritesRail = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
row-gap: 6px;
|
||||
`;
|
||||
|
||||
type SidebarRailFavoritesProps = {
|
||||
favoritesNav: SidebarItemDef[];
|
||||
highlightedItemId?: string;
|
||||
onSelectPageItem: (itemId: string) => void;
|
||||
selectedItemId: string;
|
||||
};
|
||||
|
||||
export function SidebarRailFavorites({
|
||||
favoritesNav,
|
||||
highlightedItemId,
|
||||
onSelectPageItem,
|
||||
selectedItemId,
|
||||
}: SidebarRailFavoritesProps) {
|
||||
return (
|
||||
<FavoritesRail>
|
||||
{favoritesNav.map((item) => (
|
||||
<SidebarRailItem
|
||||
active={selectedItemId === item.id}
|
||||
highlighted={highlightedItemId === item.id}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelect={onSelectPageItem}
|
||||
/>
|
||||
))}
|
||||
</FavoritesRail>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarFolderDef } from '../types';
|
||||
import { SidebarRailItem } from './SidebarRailItem';
|
||||
|
||||
const RailChildStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
padding-top: 4px;
|
||||
`;
|
||||
|
||||
type SidebarRailFolderProps = {
|
||||
collapsedOpenFolderId?: string;
|
||||
folder: SidebarFolderDef;
|
||||
highlightedItemId?: string;
|
||||
onSelectFolder: (folderId: string, firstChildItemId: string) => void;
|
||||
onSelectPageItem: (itemId: string, folderId?: string) => void;
|
||||
selectedItemId: string;
|
||||
};
|
||||
|
||||
export function SidebarRailFolder({
|
||||
collapsedOpenFolderId,
|
||||
folder,
|
||||
highlightedItemId,
|
||||
onSelectFolder,
|
||||
onSelectPageItem,
|
||||
selectedItemId,
|
||||
}: SidebarRailFolderProps) {
|
||||
const firstChild = folder.items[0];
|
||||
const isExpanded = collapsedOpenFolderId === folder.id;
|
||||
const hasActiveChild = folder.items.some(
|
||||
(item) => item.id === selectedItemId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SidebarRailItem
|
||||
active={isExpanded || hasActiveChild}
|
||||
highlighted={highlightedItemId === folder.id}
|
||||
item={folder}
|
||||
onSelect={
|
||||
firstChild
|
||||
? () => onSelectFolder(folder.id, firstChild.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<RailChildStack>
|
||||
{folder.items.map((item) => (
|
||||
<SidebarRailItem
|
||||
active={selectedItemId === item.id}
|
||||
child
|
||||
highlighted={highlightedItemId === item.id}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelect={(itemId) => onSelectPageItem(itemId, folder.id)}
|
||||
/>
|
||||
))}
|
||||
</RailChildStack>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarItemDef } from '../types';
|
||||
import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
|
||||
import { getSidebarIconToneRgb } from '../Shared/utils/get-sidebar-icon-tone-rgb';
|
||||
|
||||
const SIDEBAR_ACTIVE_BACKGROUND = 'rgba(0, 0, 0, 0.04)';
|
||||
|
||||
const railItemSharedStyles = css`
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
padding-bottom: 0;
|
||||
padding-right: 0;
|
||||
padding-top: 0;
|
||||
transition: background-color 0.14s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: ${SIDEBAR_ACTIVE_BACKGROUND};
|
||||
}
|
||||
`;
|
||||
|
||||
const RailItemButton = styled.button<{
|
||||
$active?: boolean;
|
||||
$child?: boolean;
|
||||
$highlightRgb?: string;
|
||||
$highlighted?: boolean;
|
||||
}>`
|
||||
--highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
|
||||
animation: ${({ $highlighted }) =>
|
||||
$highlighted
|
||||
? 'sidebarRailItemAppear 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
|
||||
: 'none'};
|
||||
appearance: none;
|
||||
background: ${({ $active }) =>
|
||||
$active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
|
||||
border: 0;
|
||||
height: ${({ $child }) => ($child ? '32px' : '36px')};
|
||||
padding-left: ${({ $child }) => ($child ? '8px' : '0')};
|
||||
|
||||
@keyframes sidebarRailItemAppear {
|
||||
0% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
box-shadow:
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.72);
|
||||
}
|
||||
16% {
|
||||
background: rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
|
||||
box-shadow:
|
||||
0 0 0 6px rgba(var(--highlight-rgb, 237, 95, 0), 0.4),
|
||||
0 12px 28px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1.14);
|
||||
}
|
||||
100% {
|
||||
background: ${SIDEBAR_ACTIVE_BACKGROUND};
|
||||
box-shadow:
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
|
||||
0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const RailItemLink = styled.a<{ $active?: boolean; $child?: boolean }>`
|
||||
background: ${({ $active }) =>
|
||||
$active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
|
||||
height: ${({ $child }) => ($child ? '32px' : '36px')};
|
||||
padding-left: ${({ $child }) => ($child ? '8px' : '0')};
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
type SidebarRailDisplayItem = Pick<
|
||||
SidebarItemDef,
|
||||
'href' | 'icon' | 'id' | 'label'
|
||||
>;
|
||||
|
||||
type SidebarRailItemProps = {
|
||||
active?: boolean;
|
||||
child?: boolean;
|
||||
highlighted?: boolean;
|
||||
item: SidebarRailDisplayItem;
|
||||
onSelect?: (itemId: string) => void;
|
||||
};
|
||||
|
||||
export function SidebarRailItem({
|
||||
active = false,
|
||||
child = false,
|
||||
highlighted = false,
|
||||
item,
|
||||
onSelect,
|
||||
}: SidebarRailItemProps) {
|
||||
const highlightRgb = getSidebarIconToneRgb(item.icon);
|
||||
const icon = renderPreviewIcon(item.icon, highlighted);
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<RailItemLink
|
||||
$active={active}
|
||||
$child={child}
|
||||
aria-label={item.label}
|
||||
className={railItemSharedStyles}
|
||||
data-rail-item-id={item.id}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={item.label}
|
||||
>
|
||||
{icon}
|
||||
</RailItemLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RailItemButton
|
||||
$active={active}
|
||||
$child={child}
|
||||
$highlightRgb={highlighted ? highlightRgb : undefined}
|
||||
$highlighted={highlighted}
|
||||
aria-label={item.label}
|
||||
className={railItemSharedStyles}
|
||||
data-rail-item-id={item.id}
|
||||
onClick={onSelect ? () => onSelect(item.id) : undefined}
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</RailItemButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarEntry } from '../types';
|
||||
import { isFolder } from './is-folder';
|
||||
import { SidebarRailFolder } from './SidebarRailFolder';
|
||||
import { SidebarRailItem } from './SidebarRailItem';
|
||||
|
||||
const WorkspaceRail = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const WorkspaceRailScroll = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
row-gap: 6px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type SidebarRailWorkspaceProps = {
|
||||
collapsedOpenFolderId?: string;
|
||||
highlightedItemId?: string;
|
||||
onSelectFolder: (folderId: string, firstChildItemId: string) => void;
|
||||
onSelectPageItem: (itemId: string, folderId?: string) => void;
|
||||
selectedItemId: string;
|
||||
workspaceNav: SidebarEntry[];
|
||||
};
|
||||
|
||||
export function SidebarRailWorkspace({
|
||||
collapsedOpenFolderId,
|
||||
highlightedItemId,
|
||||
onSelectFolder,
|
||||
onSelectPageItem,
|
||||
selectedItemId,
|
||||
workspaceNav,
|
||||
}: SidebarRailWorkspaceProps) {
|
||||
return (
|
||||
<WorkspaceRail>
|
||||
<WorkspaceRailScroll>
|
||||
{workspaceNav.map((entry) => {
|
||||
if (!isFolder(entry)) {
|
||||
return (
|
||||
<SidebarRailItem
|
||||
active={selectedItemId === entry.id}
|
||||
highlighted={highlightedItemId === entry.id}
|
||||
item={entry}
|
||||
key={entry.id}
|
||||
onSelect={(itemId) => onSelectPageItem(itemId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarRailFolder
|
||||
collapsedOpenFolderId={collapsedOpenFolderId}
|
||||
folder={entry}
|
||||
highlightedItemId={highlightedItemId}
|
||||
key={entry.id}
|
||||
onSelectFolder={onSelectFolder}
|
||||
onSelectPageItem={onSelectPageItem}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</WorkspaceRailScroll>
|
||||
</WorkspaceRail>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { SidebarEntry } from '../types';
|
||||
import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
|
||||
import { isFolder } from './is-folder';
|
||||
import { SidebarDesktopFolder } from './SidebarDesktopFolder';
|
||||
import { SidebarDesktopItem } from './SidebarDesktopItem';
|
||||
|
||||
const WorkspaceSection = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const WorkspaceScroll = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
row-gap: 2px;
|
||||
scrollbar-width: none;
|
||||
|
||||
padding-bottom: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkspaceLabelRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
padding-left: 4px;
|
||||
padding-right: 2px;
|
||||
`;
|
||||
|
||||
const WorkspaceLabel = styled.span`
|
||||
color: ${COLORS.textLight};
|
||||
font-family: ${APP_FONT};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
type SidebarWorkspaceProps = {
|
||||
highlightedItemId?: string;
|
||||
onSelectPageItem: (itemId: string) => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
openFolderIds: string[];
|
||||
selectedItemId: string;
|
||||
workspaceNav: SidebarEntry[];
|
||||
};
|
||||
|
||||
export function SidebarWorkspace({
|
||||
highlightedItemId,
|
||||
onSelectPageItem,
|
||||
onToggleFolder,
|
||||
openFolderIds,
|
||||
selectedItemId,
|
||||
workspaceNav,
|
||||
}: SidebarWorkspaceProps) {
|
||||
return (
|
||||
<WorkspaceSection>
|
||||
<WorkspaceLabelRow>
|
||||
<WorkspaceLabel>Workspace</WorkspaceLabel>
|
||||
</WorkspaceLabelRow>
|
||||
<WorkspaceScroll>
|
||||
{workspaceNav.map((entry) => {
|
||||
if (isFolder(entry)) {
|
||||
return (
|
||||
<SidebarDesktopFolder
|
||||
expanded={openFolderIds.includes(entry.id)}
|
||||
folder={entry}
|
||||
highlightedItemId={highlightedItemId}
|
||||
key={entry.id}
|
||||
onSelectItem={onSelectPageItem}
|
||||
onToggleExpanded={() => onToggleFolder(entry.id)}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarDesktopItem
|
||||
highlightedItemId={highlightedItemId}
|
||||
item={entry}
|
||||
key={entry.id}
|
||||
onSelect={onSelectPageItem}
|
||||
selectedItemId={selectedItemId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</WorkspaceScroll>
|
||||
</WorkspaceSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
SidebarEntry,
|
||||
SidebarFolderDef,
|
||||
SidebarItemDef,
|
||||
SidebarPageItemDef,
|
||||
} from '../types';
|
||||
|
||||
import { isFolder } from './is-folder';
|
||||
|
||||
type SidebarIndex = {
|
||||
foldersById: Map<string, SidebarFolderDef>;
|
||||
itemsById: Map<string, SidebarItemDef>;
|
||||
pageItemsById: Map<string, SidebarPageItemDef>;
|
||||
parentFolderIdsByItemId: Map<string, string>;
|
||||
};
|
||||
|
||||
function isPageItem(item: SidebarItemDef): item is SidebarPageItemDef {
|
||||
return item.page !== undefined;
|
||||
}
|
||||
|
||||
function registerUniqueId(
|
||||
seenIds: Set<string>,
|
||||
id: string,
|
||||
context: string,
|
||||
): void {
|
||||
if (seenIds.has(id)) {
|
||||
throw new Error(
|
||||
`AppPreview sidebar contains a duplicate id "${id}" in ${context}.`,
|
||||
);
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
}
|
||||
|
||||
function indexEntries(
|
||||
entries: SidebarEntry[],
|
||||
context: string,
|
||||
seenIds: Set<string>,
|
||||
): SidebarIndex {
|
||||
const foldersById = new Map<string, SidebarFolderDef>();
|
||||
const itemsById = new Map<string, SidebarItemDef>();
|
||||
const pageItemsById = new Map<string, SidebarPageItemDef>();
|
||||
const parentFolderIdsByItemId = new Map<string, string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isFolder(entry)) {
|
||||
registerUniqueId(seenIds, entry.id, `${context} folder`);
|
||||
foldersById.set(entry.id, entry);
|
||||
|
||||
for (const item of entry.items) {
|
||||
registerUniqueId(
|
||||
seenIds,
|
||||
item.id,
|
||||
`${context} folder "${entry.id}" child item`,
|
||||
);
|
||||
itemsById.set(item.id, item);
|
||||
pageItemsById.set(item.id, item);
|
||||
parentFolderIdsByItemId.set(item.id, entry.id);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
registerUniqueId(seenIds, entry.id, `${context} item`);
|
||||
itemsById.set(entry.id, entry);
|
||||
|
||||
if (isPageItem(entry)) {
|
||||
pageItemsById.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
foldersById,
|
||||
itemsById,
|
||||
pageItemsById,
|
||||
parentFolderIdsByItemId,
|
||||
};
|
||||
}
|
||||
|
||||
type SidebarIndexSource = {
|
||||
favorites: SidebarItemDef[];
|
||||
workspace: SidebarEntry[];
|
||||
};
|
||||
|
||||
export function buildSidebarIndex({
|
||||
favorites,
|
||||
workspace,
|
||||
}: SidebarIndexSource): SidebarIndex {
|
||||
const seenIds = new Set<string>();
|
||||
const favoriteIndex = indexEntries(favorites, 'favorites', seenIds);
|
||||
const workspaceIndex = indexEntries(workspace, 'workspace', seenIds);
|
||||
|
||||
return {
|
||||
foldersById: workspaceIndex.foldersById,
|
||||
itemsById: new Map([
|
||||
...favoriteIndex.itemsById.entries(),
|
||||
...workspaceIndex.itemsById.entries(),
|
||||
]),
|
||||
pageItemsById: new Map([
|
||||
...favoriteIndex.pageItemsById.entries(),
|
||||
...workspaceIndex.pageItemsById.entries(),
|
||||
]),
|
||||
parentFolderIdsByItemId: workspaceIndex.parentFolderIdsByItemId,
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { SidebarEntry, SidebarItemDef } from '../types';
|
||||
import type { PageDefaults } from '../Data/page-defaults';
|
||||
import { normalizePage } from '../Data/normalize-page';
|
||||
|
||||
import { isFolder } from './is-folder';
|
||||
|
||||
function hasRenderablePage(
|
||||
item: SidebarItemDef,
|
||||
pageDefaults: PageDefaults,
|
||||
): boolean {
|
||||
return normalizePage(item, pageDefaults) !== null;
|
||||
}
|
||||
|
||||
export function findActiveItem(
|
||||
entries: SidebarEntry[],
|
||||
activeLabel: string,
|
||||
pageDefaults: PageDefaults,
|
||||
): SidebarItemDef | undefined {
|
||||
for (const entry of entries) {
|
||||
if (isFolder(entry)) {
|
||||
for (const child of entry.items) {
|
||||
if (child.label === activeLabel) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.children) {
|
||||
for (const child of entry.children) {
|
||||
if (child.label === activeLabel) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.label === activeLabel) {
|
||||
if (
|
||||
!hasRenderablePage(entry, pageDefaults) &&
|
||||
entry.children &&
|
||||
entry.children.length > 0
|
||||
) {
|
||||
const firstChildWithRenderablePage = entry.children.find((child) =>
|
||||
hasRenderablePage(child, pageDefaults),
|
||||
);
|
||||
|
||||
if (firstChildWithRenderablePage) {
|
||||
return firstChildWithRenderablePage;
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { SidebarEntry } from '../types';
|
||||
|
||||
import { isFolder } from './is-folder';
|
||||
|
||||
export function findContainingFolderId(
|
||||
entries: SidebarEntry[],
|
||||
label: string,
|
||||
): string | undefined {
|
||||
for (const entry of entries) {
|
||||
if (!isFolder(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.items.some(
|
||||
(item) =>
|
||||
item.label === label ||
|
||||
item.children?.some((child) => child.label === label) === true,
|
||||
)
|
||||
) {
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useTimeoutRegistry } from '@/lib/react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { COMPANIES_ITEM_ID, CRM_OBJECT_SEQUENCE } from '../Data/rocket-object';
|
||||
import type { SidebarEntry } from '../types';
|
||||
|
||||
const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id);
|
||||
const COMPLETED_REVEALED_OBJECT_IDS = [
|
||||
...COMPLETED_CREATED_OBJECT_IDS,
|
||||
COMPANIES_ITEM_ID,
|
||||
];
|
||||
const HIGHLIGHT_RESET_DELAY_MS = 2000;
|
||||
|
||||
export const COMPLETED_ACTIVE_OBJECT_ID = CRM_OBJECT_SEQUENCE.at(-1)!.id;
|
||||
|
||||
type AppPreviewCrmScenario = {
|
||||
handleJumpToConversationEnd: () => void;
|
||||
handleObjectCreated: (id: string) => void;
|
||||
handleScenarioReset: () => void;
|
||||
highlightedItemId: string | null;
|
||||
revealedObjectIds: string[];
|
||||
workspaceEntries: SidebarEntry[];
|
||||
};
|
||||
|
||||
export function useAppPreviewCrmScenario(
|
||||
baseWorkspaceEntries: SidebarEntry[],
|
||||
): AppPreviewCrmScenario {
|
||||
const timeoutRegistry = useTimeoutRegistry();
|
||||
const [createdObjectIds, setCreatedObjectIds] = useState<string[]>([]);
|
||||
const [revealedObjectIds, setRevealedObjectIds] = useState<string[]>([]);
|
||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const workspaceEntries = useMemo<SidebarEntry[]>(() => {
|
||||
if (createdObjectIds.length === 0) {
|
||||
return baseWorkspaceEntries;
|
||||
}
|
||||
|
||||
const prependedEntries = [...createdObjectIds]
|
||||
.reverse()
|
||||
.map(
|
||||
(id) =>
|
||||
CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id)
|
||||
?.sidebarItem,
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is NonNullable<typeof entry> => entry !== undefined,
|
||||
);
|
||||
|
||||
return [...prependedEntries, ...baseWorkspaceEntries];
|
||||
}, [baseWorkspaceEntries, createdObjectIds]);
|
||||
|
||||
const handleObjectCreated = useCallback((id: string) => {
|
||||
setRevealedObjectIds((current) =>
|
||||
current.includes(id) ? current : [...current, id],
|
||||
);
|
||||
setHighlightedItemId(id);
|
||||
|
||||
if (id === COMPANIES_ITEM_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sequenceEntry = CRM_OBJECT_SEQUENCE.find(
|
||||
(candidate) => candidate.id === id,
|
||||
);
|
||||
|
||||
if (!sequenceEntry) {
|
||||
throw new Error(
|
||||
`AppPreview CRM scenario does not support object id "${id}".`,
|
||||
);
|
||||
}
|
||||
|
||||
setCreatedObjectIds((current) =>
|
||||
current.includes(id) ? current : [...current, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleScenarioReset = useCallback(() => {
|
||||
setCreatedObjectIds([]);
|
||||
setRevealedObjectIds([]);
|
||||
setHighlightedItemId(null);
|
||||
}, []);
|
||||
|
||||
const handleJumpToConversationEnd = useCallback(() => {
|
||||
setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS);
|
||||
setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS);
|
||||
setHighlightedItemId(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedItemId === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return timeoutRegistry.schedule(
|
||||
() => setHighlightedItemId(null),
|
||||
HIGHLIGHT_RESET_DELAY_MS,
|
||||
);
|
||||
}, [highlightedItemId, timeoutRegistry]);
|
||||
|
||||
return {
|
||||
handleJumpToConversationEnd,
|
||||
handleObjectCreated,
|
||||
handleScenarioReset,
|
||||
highlightedItemId,
|
||||
revealedObjectIds,
|
||||
workspaceEntries,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { AppPreviewConfig, SidebarEntry, SidebarItemDef } from '../types';
|
||||
import { COMPANIES_ITEM_ID } from '../Data/rocket-object';
|
||||
import {
|
||||
COMPLETED_ACTIVE_OBJECT_ID,
|
||||
useAppPreviewCrmScenario,
|
||||
} from './use-app-preview-crm-scenario';
|
||||
import { useAppPreviewNavigation } from './use-app-preview-navigation';
|
||||
|
||||
function buildItemListSignature(items: SidebarItemDef[]) {
|
||||
return items.map((item) => item.id).join('|');
|
||||
}
|
||||
|
||||
function buildWorkspaceSignature(entries: SidebarEntry[]) {
|
||||
return entries
|
||||
.map((entry) =>
|
||||
'items' in entry
|
||||
? `folder:${entry.id}[${entry.items.map((item) => item.id).join(',')}]`
|
||||
: `item:${entry.id}`,
|
||||
)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function buildSidebarConfigResetKey(visual: AppPreviewConfig) {
|
||||
return [
|
||||
visual.sidebar.initialActiveItemId,
|
||||
visual.sidebar.initialOpenFolderIds.join('|'),
|
||||
buildItemListSignature(visual.sidebar.favorites),
|
||||
buildWorkspaceSignature(visual.sidebar.workspace),
|
||||
].join('::');
|
||||
}
|
||||
|
||||
export function useAppPreviewExperience(visual: AppPreviewConfig) {
|
||||
const sidebarConfigResetKey = useMemo(
|
||||
() => buildSidebarConfigResetKey(visual),
|
||||
[visual],
|
||||
);
|
||||
const previousSidebarConfigResetKeyRef = useRef(sidebarConfigResetKey);
|
||||
const scenario = useAppPreviewCrmScenario(visual.sidebar.workspace);
|
||||
const navigation = useAppPreviewNavigation({
|
||||
defaultViewbarActions: visual.defaultViewbarActions,
|
||||
sidebar: {
|
||||
favorites: visual.sidebar.favorites,
|
||||
initialActiveItemId: visual.sidebar.initialActiveItemId,
|
||||
initialOpenFolderIds: visual.sidebar.initialOpenFolderIds,
|
||||
workspace: scenario.workspaceEntries,
|
||||
},
|
||||
});
|
||||
const {
|
||||
handleJumpToConversationEnd: completeScenario,
|
||||
handleObjectCreated: revealObject,
|
||||
handleScenarioReset,
|
||||
highlightedItemId,
|
||||
revealedObjectIds,
|
||||
} = scenario;
|
||||
const { canSelectPageItem, resetNavigation, selectPageItem } = navigation;
|
||||
const [pendingPageItemSelectionId, setPendingPageItemSelectionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pendingPageItemSelectionId === null ||
|
||||
!canSelectPageItem(pendingPageItemSelectionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectPageItem(pendingPageItemSelectionId);
|
||||
setPendingPageItemSelectionId(null);
|
||||
}, [canSelectPageItem, pendingPageItemSelectionId, selectPageItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSidebarConfigResetKeyRef.current === sidebarConfigResetKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousSidebarConfigResetKeyRef.current = sidebarConfigResetKey;
|
||||
setPendingPageItemSelectionId(null);
|
||||
handleScenarioReset();
|
||||
resetNavigation();
|
||||
}, [handleScenarioReset, resetNavigation, sidebarConfigResetKey]);
|
||||
|
||||
const handleObjectCreated = useCallback(
|
||||
(id: string) => {
|
||||
revealObject(id);
|
||||
|
||||
if (id === COMPANIES_ITEM_ID) {
|
||||
selectPageItem(COMPANIES_ITEM_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingPageItemSelectionId(id);
|
||||
},
|
||||
[revealObject, selectPageItem],
|
||||
);
|
||||
|
||||
const handleChatReset = useCallback(() => {
|
||||
setPendingPageItemSelectionId(null);
|
||||
handleScenarioReset();
|
||||
resetNavigation();
|
||||
}, [handleScenarioReset, resetNavigation]);
|
||||
|
||||
const handleJumpToConversationEnd = useCallback(() => {
|
||||
completeScenario();
|
||||
setPendingPageItemSelectionId(COMPLETED_ACTIVE_OBJECT_ID);
|
||||
}, [completeScenario]);
|
||||
|
||||
return {
|
||||
...navigation,
|
||||
handleChatReset,
|
||||
handleJumpToConversationEnd,
|
||||
handleObjectCreated,
|
||||
highlightedItemId,
|
||||
revealedObjectIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePage } from '../Data/normalize-page';
|
||||
import type {
|
||||
AppPreviewConfig,
|
||||
AppPreviewSidebarConfig,
|
||||
PageDefinition,
|
||||
SidebarEntry,
|
||||
SidebarItemDef,
|
||||
SidebarPageItemDef,
|
||||
} from '../types';
|
||||
import { buildSidebarIndex } from './build-sidebar-index';
|
||||
|
||||
const DEFAULT_TABLE_WIDTH = 1700;
|
||||
|
||||
function getInitialOpenFolderIds(
|
||||
sidebar: AppPreviewSidebarConfig,
|
||||
activeItemId: string,
|
||||
workspaceEntries: SidebarEntry[],
|
||||
) {
|
||||
const navigationIndex = buildSidebarIndex({
|
||||
favorites: sidebar.favorites,
|
||||
workspace: workspaceEntries,
|
||||
});
|
||||
|
||||
const defaultActiveItem = navigationIndex.pageItemsById.get(activeItemId);
|
||||
|
||||
if (!defaultActiveItem) {
|
||||
throw new Error(
|
||||
`AppPreviewConfig references unknown initial active item id "${activeItemId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const folderId of sidebar.initialOpenFolderIds) {
|
||||
if (!navigationIndex.foldersById.has(folderId)) {
|
||||
throw new Error(
|
||||
`AppPreviewConfig references unknown initial open folder id "${folderId}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const openFolderIds = new Set(sidebar.initialOpenFolderIds);
|
||||
const parentFolderId =
|
||||
navigationIndex.parentFolderIdsByItemId.get(activeItemId);
|
||||
|
||||
if (parentFolderId) {
|
||||
openFolderIds.add(parentFolderId);
|
||||
}
|
||||
|
||||
return [...openFolderIds];
|
||||
}
|
||||
|
||||
type AppPreviewNavigationConfig = Pick<
|
||||
AppPreviewConfig,
|
||||
'defaultViewbarActions'
|
||||
> & {
|
||||
sidebar: Pick<
|
||||
AppPreviewSidebarConfig,
|
||||
'favorites' | 'initialActiveItemId' | 'initialOpenFolderIds'
|
||||
> & {
|
||||
workspace: SidebarEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
type AppPreviewNavigationState = {
|
||||
activeItem: SidebarPageItemDef;
|
||||
activeItemId: string;
|
||||
activeItemLabel: string;
|
||||
activePage: PageDefinition;
|
||||
canSelectPageItem: (itemId: string) => boolean;
|
||||
favorites: SidebarItemDef[];
|
||||
openFolderIds: string[];
|
||||
resetNavigation: () => void;
|
||||
selectPageItem: (itemId: string) => void;
|
||||
toggleFolder: (folderId: string) => void;
|
||||
workspaceEntries: SidebarEntry[];
|
||||
};
|
||||
|
||||
function getVisibleSidebarItems(items: SidebarItemDef[]) {
|
||||
return items.filter((item) => !item.hidden);
|
||||
}
|
||||
|
||||
function getVisibleSidebarEntries(entries: SidebarEntry[]): SidebarEntry[] {
|
||||
return entries.reduce<SidebarEntry[]>((visibleEntries, entry) => {
|
||||
if ('items' in entry) {
|
||||
const visibleItems = entry.items.filter((item) => !item.hidden);
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return visibleEntries;
|
||||
}
|
||||
|
||||
visibleEntries.push({ ...entry, items: visibleItems });
|
||||
return visibleEntries;
|
||||
}
|
||||
|
||||
if (!entry.hidden) {
|
||||
visibleEntries.push(entry);
|
||||
}
|
||||
|
||||
return visibleEntries;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAppPreviewNavigation(
|
||||
visual: AppPreviewNavigationConfig,
|
||||
): AppPreviewNavigationState {
|
||||
const defaultActiveItemId = visual.sidebar.initialActiveItemId;
|
||||
|
||||
const navigationIndex = useMemo(
|
||||
() =>
|
||||
buildSidebarIndex({
|
||||
favorites: visual.sidebar.favorites,
|
||||
workspace: visual.sidebar.workspace,
|
||||
}),
|
||||
[visual.sidebar.favorites, visual.sidebar.workspace],
|
||||
);
|
||||
|
||||
const [activeItemId, setActiveItemId] = useState(defaultActiveItemId);
|
||||
const [openFolderIds, setOpenFolderIds] = useState(() =>
|
||||
getInitialOpenFolderIds(
|
||||
visual.sidebar,
|
||||
defaultActiveItemId,
|
||||
visual.sidebar.workspace,
|
||||
),
|
||||
);
|
||||
|
||||
const pageDefaults = useMemo(
|
||||
() => ({
|
||||
defaultActions: visual.defaultViewbarActions,
|
||||
defaultTableWidth: DEFAULT_TABLE_WIDTH,
|
||||
}),
|
||||
[visual.defaultViewbarActions],
|
||||
);
|
||||
|
||||
const activeItem = navigationIndex.pageItemsById.get(activeItemId);
|
||||
|
||||
if (!activeItem) {
|
||||
throw new Error(
|
||||
`AppPreview attempted to select unknown page item id "${activeItemId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const activePage = normalizePage(activeItem, pageDefaults);
|
||||
|
||||
const canSelectPageItem = useCallback(
|
||||
(itemId: string) => navigationIndex.pageItemsById.has(itemId),
|
||||
[navigationIndex.pageItemsById],
|
||||
);
|
||||
|
||||
const selectPageItem = useCallback(
|
||||
(itemId: string) => {
|
||||
if (!canSelectPageItem(itemId)) {
|
||||
throw new Error(
|
||||
`AppPreview attempted to select unknown page item id "${itemId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
setActiveItemId(itemId);
|
||||
|
||||
const containingFolderId =
|
||||
navigationIndex.parentFolderIdsByItemId.get(itemId);
|
||||
|
||||
if (!containingFolderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenFolderIds((current) =>
|
||||
current.includes(containingFolderId)
|
||||
? current
|
||||
: [...current, containingFolderId],
|
||||
);
|
||||
},
|
||||
[canSelectPageItem, navigationIndex.parentFolderIdsByItemId],
|
||||
);
|
||||
|
||||
const toggleFolder = useCallback(
|
||||
(folderId: string) => {
|
||||
if (!navigationIndex.foldersById.has(folderId)) {
|
||||
throw new Error(
|
||||
`AppPreview attempted to toggle unknown folder id "${folderId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
setOpenFolderIds((current) =>
|
||||
current.includes(folderId)
|
||||
? current.filter((id) => id !== folderId)
|
||||
: [...current, folderId],
|
||||
);
|
||||
},
|
||||
[navigationIndex.foldersById],
|
||||
);
|
||||
|
||||
const resetNavigation = useCallback(() => {
|
||||
setActiveItemId(defaultActiveItemId);
|
||||
setOpenFolderIds(
|
||||
getInitialOpenFolderIds(
|
||||
visual.sidebar,
|
||||
defaultActiveItemId,
|
||||
visual.sidebar.workspace,
|
||||
),
|
||||
);
|
||||
}, [defaultActiveItemId, visual.sidebar]);
|
||||
|
||||
return {
|
||||
activeItem,
|
||||
activeItemId,
|
||||
activeItemLabel: activeItem.label,
|
||||
activePage,
|
||||
canSelectPageItem,
|
||||
favorites: getVisibleSidebarItems(visual.sidebar.favorites),
|
||||
openFolderIds,
|
||||
resetNavigation,
|
||||
selectPageItem,
|
||||
toggleFolder,
|
||||
workspaceEntries: getVisibleSidebarEntries(visual.sidebar.workspace),
|
||||
};
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useTimeoutRegistry } from '@/lib/react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { SidebarEntry, AppPreviewConfig } from '../types';
|
||||
import { normalizePage } from '../Data/normalize-page';
|
||||
import { findActiveItem } from './find-active-item';
|
||||
import { findContainingFolderId } from './find-containing-folder-id';
|
||||
import { isFolder } from './is-folder';
|
||||
import {
|
||||
COMPANIES_ITEM_ID,
|
||||
COMPANIES_ITEM_LABEL,
|
||||
CRM_OBJECT_SEQUENCE,
|
||||
} from '../Data/rocket-object';
|
||||
|
||||
const DEFAULT_TABLE_WIDTH = 1700;
|
||||
const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id);
|
||||
const COMPLETED_REVEALED_OBJECT_IDS = [
|
||||
...COMPLETED_CREATED_OBJECT_IDS,
|
||||
COMPANIES_ITEM_ID,
|
||||
];
|
||||
const COMPLETED_ACTIVE_OBJECT_LABEL =
|
||||
CRM_OBJECT_SEQUENCE.at(-1)?.label ?? COMPANIES_ITEM_LABEL;
|
||||
const HIGHLIGHT_RESET_DELAY_MS = 2000;
|
||||
|
||||
export function useAppPreviewState(visual: AppPreviewConfig) {
|
||||
const timeoutRegistry = useTimeoutRegistry();
|
||||
const defaultActiveLabel =
|
||||
visual.favoritesNav?.find((item) => item.active)?.label ??
|
||||
visual.workspaceNav.find((entry) => !isFolder(entry) && entry.active)
|
||||
?.label ??
|
||||
visual.workspaceNav[0]?.label ??
|
||||
'';
|
||||
|
||||
const [activeLabel, setActiveLabel] = useState(defaultActiveLabel);
|
||||
const [createdObjectIds, setCreatedObjectIds] = useState<string[]>([]);
|
||||
const [revealedObjectIds, setRevealedObjectIds] = useState<string[]>([]);
|
||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [openFolderIds, setOpenFolderIds] = useState(() => {
|
||||
const activeFolderId = findContainingFolderId(
|
||||
visual.workspaceNav,
|
||||
defaultActiveLabel,
|
||||
);
|
||||
|
||||
return visual.workspaceNav.flatMap((entry) => {
|
||||
if (!isFolder(entry)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (entry.defaultOpen || entry.id === activeFolderId) {
|
||||
return [entry.id];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
const pageDefaults = useMemo(
|
||||
() => ({
|
||||
defaultActions: visual.actions ?? [],
|
||||
defaultTableWidth: visual.tableWidth ?? DEFAULT_TABLE_WIDTH,
|
||||
}),
|
||||
[visual.actions, visual.tableWidth],
|
||||
);
|
||||
|
||||
const workspaceNav = useMemo<SidebarEntry[]>(() => {
|
||||
if (createdObjectIds.length === 0) {
|
||||
return visual.workspaceNav;
|
||||
}
|
||||
|
||||
const prepended = [...createdObjectIds]
|
||||
.reverse()
|
||||
.map(
|
||||
(id) =>
|
||||
CRM_OBJECT_SEQUENCE.find((entry) => entry.id === id)?.sidebarItem,
|
||||
)
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined);
|
||||
|
||||
return [...prepended, ...visual.workspaceNav];
|
||||
}, [createdObjectIds, visual.workspaceNav]);
|
||||
|
||||
const handleObjectCreated = useCallback((id: string) => {
|
||||
setRevealedObjectIds((current) =>
|
||||
current.includes(id) ? current : [...current, id],
|
||||
);
|
||||
|
||||
if (id === COMPANIES_ITEM_ID) {
|
||||
setActiveLabel(COMPANIES_ITEM_LABEL);
|
||||
setHighlightedItemId(COMPANIES_ITEM_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id);
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatedObjectIds((current) =>
|
||||
current.includes(id) ? current : [...current, id],
|
||||
);
|
||||
setActiveLabel(entry.label);
|
||||
setHighlightedItemId(entry.id);
|
||||
}, []);
|
||||
|
||||
const handleChatReset = useCallback(() => {
|
||||
setCreatedObjectIds([]);
|
||||
setRevealedObjectIds([]);
|
||||
setHighlightedItemId(null);
|
||||
setActiveLabel(defaultActiveLabel);
|
||||
}, [defaultActiveLabel]);
|
||||
|
||||
const handleJumpToConversationEnd = useCallback(() => {
|
||||
setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS);
|
||||
setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS);
|
||||
setHighlightedItemId(null);
|
||||
setActiveLabel(COMPLETED_ACTIVE_OBJECT_LABEL);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedItemId === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return timeoutRegistry.schedule(
|
||||
() => setHighlightedItemId(null),
|
||||
HIGHLIGHT_RESET_DELAY_MS,
|
||||
);
|
||||
}, [highlightedItemId, timeoutRegistry]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() =>
|
||||
(visual.favoritesNav
|
||||
? findActiveItem(visual.favoritesNav, activeLabel, pageDefaults)
|
||||
: undefined) ?? findActiveItem(workspaceNav, activeLabel, pageDefaults),
|
||||
[activeLabel, pageDefaults, visual.favoritesNav, workspaceNav],
|
||||
);
|
||||
|
||||
const activePage = useMemo(
|
||||
() => (activeItem ? normalizePage(activeItem, pageDefaults) : null),
|
||||
[activeItem, pageDefaults],
|
||||
);
|
||||
|
||||
const handleSelectLabel = useCallback(
|
||||
(label: string) => {
|
||||
setActiveLabel(label);
|
||||
|
||||
const containingFolderId = findContainingFolderId(workspaceNav, label);
|
||||
|
||||
if (!containingFolderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenFolderIds((current) =>
|
||||
current.includes(containingFolderId)
|
||||
? current
|
||||
: [...current, containingFolderId],
|
||||
);
|
||||
},
|
||||
[workspaceNav],
|
||||
);
|
||||
|
||||
const handleToggleFolder = useCallback((folderId: string) => {
|
||||
setOpenFolderIds((current) =>
|
||||
current.includes(folderId)
|
||||
? current.filter((id) => id !== folderId)
|
||||
: [...current, folderId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeItem,
|
||||
activeLabel,
|
||||
activePage,
|
||||
handleChatReset,
|
||||
handleJumpToConversationEnd,
|
||||
handleObjectCreated,
|
||||
handleSelectLabel,
|
||||
handleToggleFolder,
|
||||
highlightedItemId,
|
||||
openFolderIds,
|
||||
revealedObjectIds,
|
||||
workspaceNav,
|
||||
};
|
||||
}
|
||||
+18
-4
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useLatestRef } from '@/lib/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ASSISTANT_RESPONSE_STREAMING_STAGES } from '../utils/assistant-response-streaming-stages';
|
||||
import { buildAssistantResponseSegments } from './AssistantResponseSegments';
|
||||
@@ -48,16 +49,29 @@ export const AssistantResponse = ({
|
||||
onObjectCreated,
|
||||
onChatFinished,
|
||||
}: AssistantResponseProps) => {
|
||||
const onObjectCreatedRef = useLatestRef(onObjectCreated);
|
||||
const { createStageCompletionHandler, hasReachedStage, stage } =
|
||||
useAssistantResponseStage({
|
||||
instantComplete,
|
||||
onChatFinished,
|
||||
});
|
||||
const objectCreationHandler = instantComplete ? undefined : onObjectCreated;
|
||||
const objectCreationHandler = useCallback(
|
||||
(id: string) => {
|
||||
if (instantComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
onObjectCreatedRef.current?.(id);
|
||||
},
|
||||
[instantComplete, onObjectCreatedRef],
|
||||
);
|
||||
|
||||
const segmentsByStage = useMemo(
|
||||
() => buildAssistantResponseSegments(objectCreationHandler),
|
||||
[objectCreationHandler],
|
||||
() =>
|
||||
buildAssistantResponseSegments(
|
||||
instantComplete ? undefined : objectCreationHandler,
|
||||
),
|
||||
[instantComplete, objectCreationHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useTerminalWindowLayout } from './hooks/use-terminal-window-layout';
|
||||
import type { TerminalToggleValue } from './types/terminal-toggle-types';
|
||||
import { TERMINAL_TOKENS } from './utils/terminal-tokens';
|
||||
|
||||
const HIDE_BELOW_VIEWPORT_WIDTH = 1350;
|
||||
|
||||
const Shell = styled.div<{
|
||||
$isDragging: boolean;
|
||||
$isResizing: boolean;
|
||||
@@ -37,6 +39,10 @@ const Shell = styled.div<{
|
||||
top: 0;
|
||||
touch-action: none;
|
||||
|
||||
@media (max-width: ${HIDE_BELOW_VIEWPORT_WIDTH - 0.02}px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
box-shadow: ${({ $isDragging, $isResizing }) =>
|
||||
$isDragging || $isResizing
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ describe('terminal-window-geometry', () => {
|
||||
|
||||
it('places the initial terminal at the desktop bottom-right anchor', () => {
|
||||
expect(getInitialTerminalLayout({ width: 1000, height: 700 })).toEqual({
|
||||
position: { left: 620, top: 384 },
|
||||
position: { left: 768, top: 448 },
|
||||
size: { width: 380, height: 220 },
|
||||
});
|
||||
});
|
||||
|
||||
+6
-2
@@ -12,7 +12,8 @@ export const TERMINAL_EDITOR_WIDTH = 720;
|
||||
export const TERMINAL_EDITOR_HEIGHT = 480;
|
||||
export const TERMINAL_MIN_WIDTH = 300;
|
||||
export const TERMINAL_MIN_HEIGHT = 200;
|
||||
export const TERMINAL_INITIAL_BOTTOM_OFFSET = 96;
|
||||
export const TERMINAL_INITIAL_BOTTOM_OFFSET = 32;
|
||||
export const TERMINAL_INITIAL_RIGHT_OFFSET = 148;
|
||||
export const TERMINAL_EDGE_GAP = 0;
|
||||
export const TERMINAL_MOBILE_PARENT_BREAKPOINT = 640;
|
||||
export const TERMINAL_MOBILE_OFFSET_X = 16;
|
||||
@@ -113,7 +114,10 @@ export const getInitialTerminalLayout = (
|
||||
|
||||
return {
|
||||
position: {
|
||||
left: Math.max(0, bounds.width - size.width),
|
||||
left: Math.max(
|
||||
0,
|
||||
bounds.width - size.width + TERMINAL_INITIAL_RIGHT_OFFSET,
|
||||
),
|
||||
top: Math.max(
|
||||
0,
|
||||
bounds.height - size.height - TERMINAL_INITIAL_BOTTOM_OFFSET,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
export { AppPreview } from './AppPreview';
|
||||
export { APP_PREVIEW_DATA } from './app-preview.data';
|
||||
export { Chip } from './Shared/components/Chip';
|
||||
export { type ChipProps } from './Shared/utils/chip-props';
|
||||
export { ChipVariant } from './Shared/utils/chip-variant';
|
||||
export { VISUAL_TOKENS } from './Shared/utils/app-preview-tokens';
|
||||
export type { AppPreviewFrameMode } from './AppWindow/AppPreviewFrame';
|
||||
export type {
|
||||
AppPreviewConfig,
|
||||
AppPreviewSidebarConfig,
|
||||
DashboardData,
|
||||
DashboardPageDefinition,
|
||||
KanbanPageDefinition,
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
export type CellText = {
|
||||
type: 'text';
|
||||
targetLabel?: string;
|
||||
targetPageItemId?: string;
|
||||
value: string;
|
||||
shortLabel?: string;
|
||||
tone?: string;
|
||||
};
|
||||
export type CellNumber = { type: 'number'; value: string };
|
||||
export type CellLink = { type: 'link'; value: string };
|
||||
export type CellCurrency = { type: 'currency'; value: string };
|
||||
|
||||
export type CellLink = {
|
||||
type: 'link';
|
||||
kind?: 'email' | 'phone' | 'social' | 'url';
|
||||
label?: string;
|
||||
value: string;
|
||||
};
|
||||
export type CellBoolean = { type: 'boolean'; value: boolean };
|
||||
export type CellTag = { type: 'tag'; value: string };
|
||||
|
||||
export type CellSelect = {
|
||||
type: 'select';
|
||||
color?:
|
||||
| 'amber'
|
||||
| 'blue'
|
||||
| 'gray'
|
||||
| 'green'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'teal';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CellPerson = {
|
||||
type: 'person';
|
||||
@@ -33,9 +54,10 @@ export type CellRelation = {
|
||||
export type CellValue =
|
||||
| CellText
|
||||
| CellNumber
|
||||
| CellCurrency
|
||||
| CellLink
|
||||
| CellBoolean
|
||||
| CellTag
|
||||
| CellSelect
|
||||
| CellPerson
|
||||
| CellEntity
|
||||
| CellRelation;
|
||||
@@ -53,24 +75,53 @@ export type RowDef = {
|
||||
cells: Record<string, CellValue>;
|
||||
};
|
||||
|
||||
export type DashboardMetric = {
|
||||
id: string;
|
||||
title: string;
|
||||
export type DashboardTrend = {
|
||||
direction: 'up' | 'down';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type DashboardChartImage = {
|
||||
alt: string;
|
||||
height: number;
|
||||
src: string;
|
||||
width: number;
|
||||
export type DashboardKpi = {
|
||||
id: string;
|
||||
title: string;
|
||||
value: string;
|
||||
trend?: DashboardTrend;
|
||||
};
|
||||
|
||||
export type DashboardBar = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type DashboardBarChart = {
|
||||
title: string;
|
||||
bars: DashboardBar[];
|
||||
};
|
||||
|
||||
export type DashboardLineChart = {
|
||||
title: string;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
};
|
||||
|
||||
export type DashboardDonutSlice = {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type DashboardDonutChart = {
|
||||
title: string;
|
||||
centerLabel: string;
|
||||
centerValue: string;
|
||||
slices: DashboardDonutSlice[];
|
||||
};
|
||||
|
||||
export type DashboardData = {
|
||||
distributionChart: DashboardChartImage;
|
||||
metrics: DashboardMetric[];
|
||||
revenueChart: DashboardChartImage;
|
||||
visitsChart: DashboardChartImage;
|
||||
kpis: DashboardKpi[];
|
||||
barChart?: DashboardBarChart;
|
||||
donutChart?: DashboardDonutChart;
|
||||
lineChart?: DashboardLineChart;
|
||||
generating?: boolean;
|
||||
};
|
||||
|
||||
export type NavbarAction = {
|
||||
@@ -92,6 +143,7 @@ export type PageHeader = {
|
||||
|
||||
export type TablePageDefinition = {
|
||||
columns: ColumnDef[];
|
||||
generating?: boolean;
|
||||
header: PageHeader;
|
||||
rows: RowDef[];
|
||||
type: 'table';
|
||||
@@ -136,6 +188,7 @@ export type WorkflowBranchLabelDef = {
|
||||
export type WorkflowPageDefinition = {
|
||||
branchLabels?: WorkflowBranchLabelDef[];
|
||||
edges?: WorkflowEdgeDef[];
|
||||
generating?: boolean;
|
||||
header: PageHeader;
|
||||
nodes?: WorkflowNodeDef[];
|
||||
plusNode?: { x: number; y: number };
|
||||
@@ -163,21 +216,30 @@ export type KanbanLane = {
|
||||
};
|
||||
|
||||
export type KanbanPageDefinition = {
|
||||
generating?: boolean;
|
||||
header: PageHeader;
|
||||
lanes: KanbanLane[];
|
||||
type: 'kanban';
|
||||
};
|
||||
|
||||
export type RecordFieldValue =
|
||||
| CellBoolean
|
||||
| CellCurrency
|
||||
| CellLink
|
||||
| CellPerson
|
||||
| CellSelect
|
||||
| CellText;
|
||||
|
||||
export type RecordField = {
|
||||
avatarUrl?: string;
|
||||
icon?: string;
|
||||
label: string;
|
||||
value: string;
|
||||
value: RecordFieldValue;
|
||||
};
|
||||
|
||||
export type RecordRelation = {
|
||||
avatarUrl?: string;
|
||||
domain?: string;
|
||||
highlighted?: boolean;
|
||||
icon?: SidebarIcon;
|
||||
name: string;
|
||||
};
|
||||
@@ -186,11 +248,93 @@ export type RecordNote = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
highlighted?: boolean;
|
||||
relation?: { avatarUrl?: string; name: string };
|
||||
};
|
||||
|
||||
export type TimelineFieldDiff = {
|
||||
label: string;
|
||||
value: RecordFieldValue;
|
||||
};
|
||||
|
||||
export type TimelineEvent =
|
||||
| {
|
||||
kind: 'created';
|
||||
id: string;
|
||||
subject: string;
|
||||
actor: string;
|
||||
time: string;
|
||||
}
|
||||
| {
|
||||
kind: 'updated';
|
||||
id: string;
|
||||
actor: string;
|
||||
record: string;
|
||||
time: string;
|
||||
diffs: TimelineFieldDiff[];
|
||||
}
|
||||
| { kind: 'note'; id: string; actor: string; title: string; time: string }
|
||||
| {
|
||||
kind: 'calendar';
|
||||
id: string;
|
||||
actor: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
export type RecordParticipant = {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
tone?: string;
|
||||
};
|
||||
|
||||
export type RecordActivityTarget = RecordParticipant & { domain?: string };
|
||||
|
||||
export type RecordTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
due: string;
|
||||
done?: boolean;
|
||||
target: RecordActivityTarget;
|
||||
};
|
||||
|
||||
export type RecordFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'pdf' | 'sheet' | 'doc' | 'other';
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type RecordEmail = {
|
||||
id: string;
|
||||
participants: RecordParticipant[];
|
||||
count: number;
|
||||
subject: string;
|
||||
body: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type RecordCalendarEvent = {
|
||||
id: string;
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
attending?: boolean;
|
||||
participants: RecordParticipant[];
|
||||
};
|
||||
|
||||
export type RecordCalendarDay = {
|
||||
id: string;
|
||||
weekday: string;
|
||||
day: string;
|
||||
events: RecordCalendarEvent[];
|
||||
};
|
||||
|
||||
export type RecordPageDefinition = {
|
||||
type: 'record';
|
||||
activeTabLabel?: string;
|
||||
header: PageHeader;
|
||||
record: {
|
||||
logoDomain?: string;
|
||||
@@ -205,6 +349,11 @@ export type RecordPageDefinition = {
|
||||
}[];
|
||||
};
|
||||
notes: RecordNote[];
|
||||
timeline?: TimelineEvent[];
|
||||
tasks?: RecordTask[];
|
||||
files?: RecordFile[];
|
||||
emails?: RecordEmail[];
|
||||
calendar?: RecordCalendarDay[];
|
||||
};
|
||||
|
||||
export type PageDefinition =
|
||||
@@ -233,34 +382,43 @@ export type SidebarIcon =
|
||||
shape?: 'circle' | 'square';
|
||||
};
|
||||
|
||||
export type SidebarItemDef = {
|
||||
type SidebarBaseItemDef = {
|
||||
hidden?: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
icon: SidebarIcon;
|
||||
page?: PageDefinition;
|
||||
meta?: string;
|
||||
active?: boolean;
|
||||
showChevron?: boolean;
|
||||
children?: SidebarItemDef[];
|
||||
};
|
||||
|
||||
export type SidebarPageItemDef = SidebarBaseItemDef & {
|
||||
page: PageDefinition;
|
||||
href?: never;
|
||||
};
|
||||
|
||||
export type SidebarLinkItemDef = SidebarBaseItemDef & {
|
||||
href: string;
|
||||
page?: never;
|
||||
};
|
||||
|
||||
export type SidebarItemDef = SidebarLinkItemDef | SidebarPageItemDef;
|
||||
|
||||
export type SidebarFolderDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: SidebarIcon;
|
||||
defaultOpen?: boolean;
|
||||
showChevron?: boolean;
|
||||
children?: SidebarItemDef[];
|
||||
items: SidebarItemDef[];
|
||||
items: SidebarPageItemDef[];
|
||||
};
|
||||
|
||||
export type SidebarEntry = SidebarItemDef | SidebarFolderDef;
|
||||
|
||||
export type AppPreviewConfig = {
|
||||
workspace: { icon: string; name: string };
|
||||
favoritesNav?: SidebarItemDef[];
|
||||
workspaceNav: SidebarEntry[];
|
||||
tableWidth?: number;
|
||||
actions?: string[];
|
||||
export type AppPreviewSidebarConfig = {
|
||||
favorites: SidebarItemDef[];
|
||||
initialActiveItemId: string;
|
||||
initialOpenFolderIds: string[];
|
||||
workspace: SidebarEntry[];
|
||||
};
|
||||
|
||||
export type AppPreviewConfig = {
|
||||
defaultViewbarActions: string[];
|
||||
sidebar: AppPreviewSidebarConfig;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export type {
|
||||
DashboardChartImage,
|
||||
DashboardData,
|
||||
DashboardMetric,
|
||||
DashboardTrend,
|
||||
DashboardKpi,
|
||||
DashboardBar,
|
||||
DashboardBarChart,
|
||||
DashboardLineChart,
|
||||
DashboardDonutSlice,
|
||||
DashboardDonutChart,
|
||||
DashboardPageDefinition,
|
||||
KanbanCard,
|
||||
KanbanLane,
|
||||
@@ -9,12 +14,14 @@ export type {
|
||||
RecordField,
|
||||
RecordNote,
|
||||
RecordPageDefinition,
|
||||
RecordFieldValue,
|
||||
RecordRelation,
|
||||
WorkflowBranchLabelDef,
|
||||
WorkflowEdgeDef,
|
||||
WorkflowNodeDef,
|
||||
WorkflowPageDefinition,
|
||||
CellBoolean,
|
||||
CellCurrency,
|
||||
CellEntity,
|
||||
CellLink,
|
||||
CellNumber,
|
||||
@@ -23,16 +30,27 @@ export type {
|
||||
NavbarAction,
|
||||
PageHeader,
|
||||
CellRelation,
|
||||
CellTag,
|
||||
CellSelect,
|
||||
CellText,
|
||||
CellValue,
|
||||
ColumnDef,
|
||||
RowDef,
|
||||
PageType,
|
||||
SidebarEntry,
|
||||
AppPreviewSidebarConfig,
|
||||
SidebarFolderDef,
|
||||
SidebarIcon,
|
||||
SidebarLinkItemDef,
|
||||
SidebarPageItemDef,
|
||||
SidebarItemDef,
|
||||
TablePageDefinition,
|
||||
TimelineEvent,
|
||||
RecordParticipant,
|
||||
RecordActivityTarget,
|
||||
RecordTask,
|
||||
RecordFile,
|
||||
RecordEmail,
|
||||
RecordCalendarEvent,
|
||||
RecordCalendarDay,
|
||||
AppPreviewConfig,
|
||||
} from './app-preview-data';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CaseStudyData } from '@/lib/customers';
|
||||
import { Container } from '@/design-system/components';
|
||||
import { CLIENT_ICONS } from '@/icons';
|
||||
import { CLIENT_ICONS, type ClientIconKey } from '@/icons';
|
||||
import { getServerI18n } from '@/lib/i18n/server';
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover';
|
||||
@@ -9,7 +9,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconArrowLeft, IconClock } from '@tabler/icons-react';
|
||||
import Image from 'next/image';
|
||||
const CATALOG_LOGO_WIDTHS: Record<string, number> = {
|
||||
const CATALOG_LOGO_WIDTHS: Record<ClientIconKey, number> = {
|
||||
'nine-dots': 72,
|
||||
'alternative-partners': 220,
|
||||
netzero: 180,
|
||||
@@ -242,8 +242,7 @@ export function Hero({ hero, dashColor, hoverDashColor }: HeroProps) {
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
const logoWidth =
|
||||
(CATALOG_LOGO_WIDTHS[hero.clientIcon] ?? 140) * HERO_LOGO_SCALE;
|
||||
const logoWidth = CATALOG_LOGO_WIDTHS[hero.clientIcon] * HERO_LOGO_SCALE;
|
||||
|
||||
return (
|
||||
<Section id="case-study-hero">
|
||||
|
||||
@@ -86,7 +86,7 @@ export function CardThumbnail({
|
||||
variant,
|
||||
}: CardThumbnailProps) {
|
||||
const ClientIcon = CLIENT_ICONS[clientIcon];
|
||||
const baseLogoWidth = CATALOG_LOGO_WIDTHS[clientIcon] ?? 140;
|
||||
const baseLogoWidth = CATALOG_LOGO_WIDTHS[clientIcon];
|
||||
const logoWidth =
|
||||
variant === 'large' ? baseLogoWidth * LARGE_LOGO_SCALE : baseLogoWidth;
|
||||
|
||||
|
||||
@@ -134,9 +134,7 @@ export function HelpedCard({ card }: CardProps) {
|
||||
strokeColor={theme.colors.secondary.border[40]}
|
||||
/>
|
||||
<LogoRow>
|
||||
{IconComponent ? (
|
||||
<IconComponent fillColor={LOGO_FILL} size={logoWidth} />
|
||||
) : null}
|
||||
<IconComponent fillColor={LOGO_FILL} size={logoWidth} />
|
||||
</LogoRow>
|
||||
<Rule aria-hidden="true" />
|
||||
<VisualShell>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ClientIconKey } from '@/icons';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import type { HelpedVisualId } from './helped-visual-id';
|
||||
|
||||
export type HeadingCardType = {
|
||||
icon: string;
|
||||
icon: ClientIconKey;
|
||||
illustration: HelpedVisualId;
|
||||
heading: MessageDescriptor;
|
||||
body: MessageDescriptor;
|
||||
|
||||
+518
-123
@@ -1,16 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode, useId, useRef, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Container, LinkButton } from '@/design-system/components';
|
||||
import { useMediaQuery } from '@/lib/motion';
|
||||
import type { AppPreviewConfig } from '@/sections/AppPreview';
|
||||
import { TabButton } from '@/sections/Tabs/components/TabButton';
|
||||
import { TabButtons } from '@/sections/Tabs/components/TabButtons';
|
||||
import type { TabType } from '@/sections/Tabs/types';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
import { useProductHeroMenuSync } from './product-hero-menu-sync';
|
||||
import { ProductBackgroundHalftone } from './ProductBackgroundHalftone';
|
||||
import { ProductVisual } from './ProductVisual';
|
||||
import { useHeroScrollProgress } from './use-hero-scroll-progress';
|
||||
|
||||
@@ -21,70 +29,77 @@ export type HeroScrollProps = {
|
||||
ctaLabel: string;
|
||||
introBody: string;
|
||||
introHeading: ReactNode;
|
||||
introSecondaryCta?: ReactNode;
|
||||
tabs: TabType[];
|
||||
visual: AppPreviewConfig;
|
||||
};
|
||||
|
||||
type StackTargetMetric = {
|
||||
offset: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const NAV_HEIGHT = 64;
|
||||
|
||||
const MD_UP_QUERY = `(min-width: ${theme.breakpoints.md}px)`;
|
||||
const AI_PANEL_ONLY_MAX_WIDTH = 599.98;
|
||||
|
||||
const PRODUCT_HERO_BACKGROUND_IMAGE =
|
||||
'/illustrations/generated/home-background-wheat.webp';
|
||||
|
||||
const INTRO_DASH_COLOR = '#4A38F5';
|
||||
const AI_DASH_COLOR = '#ffffff';
|
||||
|
||||
const PatternOverlay = styled.div`
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const ScrollTrack = styled.section`
|
||||
height: 200vh;
|
||||
margin-top: -${NAV_HEIGHT}px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md - 0.02}px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileRoot = styled.div`
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
height: 200vh;
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StickyFrame = styled.div`
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
|
||||
background-color: #ffffff;
|
||||
`;
|
||||
|
||||
const FullLayer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inset: 0;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
padding-bottom: ${theme.spacing(6)};
|
||||
padding-top: ${theme.spacing(7.5)};
|
||||
transition: background-color 0.6s ease;
|
||||
width: 100%;
|
||||
|
||||
&[data-phase='0'] {
|
||||
background-color: var(--color-white, #ffffff);
|
||||
}
|
||||
|
||||
&[data-phase='1'] {
|
||||
background-color: var(--color-black, #141414);
|
||||
}
|
||||
padding-top: 94px;
|
||||
position: absolute;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
height: calc(100vh - ${NAV_HEIGHT}px);
|
||||
padding-bottom: 0;
|
||||
padding-top: ${theme.spacing(12)};
|
||||
position: sticky;
|
||||
top: ${NAV_HEIGHT}px;
|
||||
padding-top: 112px;
|
||||
}
|
||||
`;
|
||||
|
||||
const PatternOverlay = styled.div`
|
||||
bottom: 0;
|
||||
height: 575px;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
transition: opacity 0.6s ease;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
|
||||
&[data-visible='true'] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
`;
|
||||
|
||||
const patternImageClassName = css`
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled(Container)`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -93,7 +108,7 @@ const StyledContainer = styled(Container)`
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
position: relative;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
row-gap: ${theme.spacing(8)};
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
@@ -107,53 +122,31 @@ const StyledContainer = styled(Container)`
|
||||
const HeadingSlot = styled.div`
|
||||
max-width: 360px;
|
||||
min-height: 96px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 672px;
|
||||
}
|
||||
position: relative;
|
||||
transition: color 0.6s ease;
|
||||
width: 100%;
|
||||
|
||||
&[data-phase='0'] {
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
}
|
||||
|
||||
&[data-phase='1'] {
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
}
|
||||
`;
|
||||
|
||||
const ContentLayer = styled.div`
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 0.6s ease;
|
||||
|
||||
&[data-active='true'] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
const BodyText = styled.p`
|
||||
const BodyText = styled.div`
|
||||
font-size: ${theme.font.size(4)};
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
max-width: 360px;
|
||||
transition: color 0.6s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&[data-phase='0'] {
|
||||
color: ${theme.colors.primary.text[60]};
|
||||
}
|
||||
|
||||
&[data-phase='1'] {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 591px;
|
||||
}
|
||||
@@ -168,7 +161,7 @@ const HeadingGroup = styled.div`
|
||||
`;
|
||||
|
||||
const ActionSlot = styled.div`
|
||||
min-height: 48px;
|
||||
min-height: 40px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -179,37 +172,95 @@ const CtaLayer = styled.div`
|
||||
gap: ${theme.spacing(3)};
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
transition: opacity 0.4s ease;
|
||||
|
||||
&[data-visible='false'] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
&[data-active='true'] {
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
const TabsLayer = styled.div`
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s ease 0.2s;
|
||||
const MeasureTabButtons = styled(TabButtons)`
|
||||
width: 100%;
|
||||
|
||||
&[data-visible='true'] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
@media (min-width: ${theme.breakpoints.lg}px) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
const StackedTabDeck = styled.div`
|
||||
display: none;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.lg}px) {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const StackedTabCard = styled.div`
|
||||
left: 50%;
|
||||
max-width: min(500px, calc(100vw - 160px));
|
||||
opacity: var(--hero-stack-opacity, 0);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform-origin: center top;
|
||||
transition: none;
|
||||
width: var(--hero-stack-width, auto);
|
||||
will-change: opacity, transform;
|
||||
|
||||
& > button {
|
||||
background-color: ${theme.colors.secondary.background[100]};
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.1) 100%
|
||||
);
|
||||
border: 1px solid ${theme.colors.secondary.border[10]};
|
||||
box-sizing: border-box;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const VisualWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const MobileSection = styled.section<{ $secondary?: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-top: ${({ $secondary }) =>
|
||||
$secondary ? theme.spacing(12) : theme.spacing(7.5)};
|
||||
position: relative;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const MobileVisualWrapper = styled.div`
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
padding-bottom: ${theme.spacing(16)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
// Non-clipping layer that hosts the collaboration cursors so they can sit above
|
||||
// the visual (like the desktop FullLayer); the inner wrapper still clips the bleed.
|
||||
const MobileCursorLayer = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
@@ -220,66 +271,410 @@ export function HeroVisualScroll({
|
||||
ctaLabel,
|
||||
introBody,
|
||||
introHeading,
|
||||
introSecondaryCta,
|
||||
tabs,
|
||||
visual,
|
||||
}: HeroScrollProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const phase = useHeroScrollProgress(trackRef);
|
||||
const rowButtonsRef = useRef<HTMLDivElement>(null);
|
||||
const { morphProgress, navProgress, menuBackground, menuElevated } =
|
||||
useHeroScrollProgress(trackRef);
|
||||
const menuSync = useProductHeroMenuSync();
|
||||
const isDesktop = useMediaQuery(MD_UP_QUERY, { serverFallback: true });
|
||||
// Below 600px the board + AI panel can't coexist, so the AI section becomes a
|
||||
// panel-only "Ask AI" view.
|
||||
const isPhone = useMediaQuery(`(max-width: ${AI_PANEL_ONLY_MAX_WIDTH}px)`, {
|
||||
serverFallback: false,
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const idPrefix = useId();
|
||||
const [introLayerEl, setIntroLayerEl] = useState<HTMLDivElement | null>(null);
|
||||
const [mobileIntroLayerEl, setMobileIntroLayerEl] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const [stackTargetMetrics, setStackTargetMetrics] = useState<
|
||||
StackTargetMetric[]
|
||||
>([]);
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value));
|
||||
const stackAppearProgress = clamp((morphProgress - 0.4) / 0.16);
|
||||
const stackAlignProgress = clamp((morphProgress - 0.62) / 0.04);
|
||||
const stackSpreadProgress = clamp((morphProgress - 0.66) / 0.27);
|
||||
const stackSpreadEasedProgress = 1 - Math.pow(1 - stackSpreadProgress, 2.6);
|
||||
const selectorRevealProgress = clamp((morphProgress - 0.94) / 0.06);
|
||||
const selectorRevealReady = selectorRevealProgress > 0.96;
|
||||
const stackCards = tabs;
|
||||
|
||||
const activeScene = phase === 0 ? 0 : activeTab + 1;
|
||||
const stackStyle = {
|
||||
'--hero-stack-opacity': String(stackAppearProgress),
|
||||
'--hero-stack-shift-y': `${(1 - stackAppearProgress) * 16}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<ScrollTrack ref={trackRef}>
|
||||
<StickyFrame data-phase={phase}>
|
||||
<PatternOverlay data-visible={phase === 1 ? 'true' : 'false'}>
|
||||
<NextImage
|
||||
alt=""
|
||||
className={patternImageClassName}
|
||||
fill
|
||||
sizes="100vw"
|
||||
src="/images/product/tabs/background.webp"
|
||||
/>
|
||||
</PatternOverlay>
|
||||
const stackBaseOffsets = [0, 4, 8, 12];
|
||||
const stackBaseScales = [1, 0.99, 0.98, 0.97];
|
||||
const stackSpreadMetrics =
|
||||
stackTargetMetrics.length === tabs.length ? stackTargetMetrics : null;
|
||||
const stackWidth = stackSpreadMetrics?.[0]?.width ?? null;
|
||||
const aiPanelProgress = clamp((morphProgress - 0.45) / 0.25);
|
||||
const aiPlaybackEnabled = morphProgress >= 0.7;
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
menuSync.setMenuState({
|
||||
backgroundColor: 'rgb(255, 255, 255)',
|
||||
disableElevation: false,
|
||||
scheme: 'primary',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
menuSync.setMenuState({
|
||||
backgroundColor: menuBackground,
|
||||
disableElevation: !menuElevated,
|
||||
scheme: navProgress >= 0.5 ? 'secondary' : 'primary',
|
||||
});
|
||||
}, [menuSync, isDesktop, navProgress, menuBackground, menuElevated]);
|
||||
|
||||
const heroAtStart = morphProgress <= 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (heroAtStart) {
|
||||
setActiveTab(0);
|
||||
}
|
||||
}, [heroAtStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateStackTargets = () => {
|
||||
const rowContainer = rowButtonsRef.current;
|
||||
|
||||
if (!(rowContainer instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = Array.from(rowContainer.querySelectorAll('button'));
|
||||
|
||||
if (buttons.length !== tabs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = rowContainer.getBoundingClientRect();
|
||||
const containerCenter = containerRect.left + containerRect.width / 2;
|
||||
|
||||
setStackTargetMetrics(
|
||||
buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
offset: rect.left + rect.width / 2 - containerCenter,
|
||||
width: rect.width,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
updateStackTargets();
|
||||
|
||||
window.addEventListener('resize', updateStackTargets);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateStackTargets);
|
||||
};
|
||||
}, [tabs]);
|
||||
|
||||
const mobileLayout = (
|
||||
<MobileRoot>
|
||||
<MobileSection style={{ backgroundColor: '#ffffff' }}>
|
||||
<StyledContainer>
|
||||
<HeadingGroup>
|
||||
<HeadingSlot data-phase={phase}>
|
||||
<ContentLayer data-active={phase === 0}>
|
||||
{introHeading}
|
||||
</ContentLayer>
|
||||
<ContentLayer data-active={phase === 1}>{aiHeading}</ContentLayer>
|
||||
<HeadingSlot style={{ color: theme.colors.primary.text[100] }}>
|
||||
<ContentLayer data-active={true}>{introHeading}</ContentLayer>
|
||||
</HeadingSlot>
|
||||
|
||||
<BodyText data-phase={phase}>
|
||||
{phase === 0 ? introBody : aiBody}
|
||||
<BodyText style={{ color: theme.colors.primary.text[60] }}>
|
||||
<ContentLayer data-active={true}>{introBody}</ContentLayer>
|
||||
</BodyText>
|
||||
</HeadingGroup>
|
||||
|
||||
<ActionSlot>
|
||||
<CtaLayer data-visible={phase === 0 ? 'true' : 'false'}>
|
||||
<CtaLayer data-active={true}>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href={ctaHref}
|
||||
label={ctaLabel}
|
||||
variant="contained"
|
||||
/>
|
||||
{introSecondaryCta}
|
||||
</CtaLayer>
|
||||
<TabsLayer data-visible={phase === 1 ? 'true' : 'false'}>
|
||||
<TabButtons
|
||||
activeIndex={activeTab}
|
||||
idPrefix={idPrefix}
|
||||
onSelect={setActiveTab}
|
||||
tabs={tabs}
|
||||
/>
|
||||
</TabsLayer>
|
||||
</ActionSlot>
|
||||
</StyledContainer>
|
||||
|
||||
<VisualWrapper>
|
||||
<ProductVisual activeScene={activeScene} visual={visual} />
|
||||
</VisualWrapper>
|
||||
</StickyFrame>
|
||||
</ScrollTrack>
|
||||
<MobileCursorLayer ref={setMobileIntroLayerEl}>
|
||||
<MobileVisualWrapper>
|
||||
<PatternOverlay style={{ opacity: 1.0, zIndex: -1 }}>
|
||||
{!isDesktop ? (
|
||||
<ProductBackgroundHalftone
|
||||
dashColor={INTRO_DASH_COLOR}
|
||||
hoverColor={INTRO_DASH_COLOR}
|
||||
imageUrl={PRODUCT_HERO_BACKGROUND_IMAGE}
|
||||
/>
|
||||
) : null}
|
||||
</PatternOverlay>
|
||||
<ProductVisual
|
||||
activeScene={0}
|
||||
bleed
|
||||
collaborative
|
||||
compactCursorTour
|
||||
cursorActive={!isDesktop}
|
||||
cursorLayer={mobileIntroLayerEl}
|
||||
playbackEnabled={false}
|
||||
visual={visual}
|
||||
/>
|
||||
</MobileVisualWrapper>
|
||||
</MobileCursorLayer>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection $secondary style={{ backgroundColor: '#141414' }}>
|
||||
<StyledContainer>
|
||||
<HeadingGroup>
|
||||
<HeadingSlot style={{ color: theme.colors.secondary.text[100] }}>
|
||||
<ContentLayer data-active={true}>{aiHeading}</ContentLayer>
|
||||
</HeadingSlot>
|
||||
<BodyText style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
<ContentLayer data-active={true}>{aiBody}</ContentLayer>
|
||||
</BodyText>
|
||||
</HeadingGroup>
|
||||
|
||||
<ActionSlot>
|
||||
<CtaLayer data-active={true}>
|
||||
<TabButtons
|
||||
activeIndex={activeTab}
|
||||
idPrefix="product-hero-mobile"
|
||||
onSelect={setActiveTab}
|
||||
tabs={tabs}
|
||||
/>
|
||||
</CtaLayer>
|
||||
</ActionSlot>
|
||||
</StyledContainer>
|
||||
|
||||
<MobileVisualWrapper>
|
||||
<PatternOverlay style={{ opacity: 1.0, zIndex: -1 }}>
|
||||
{!isDesktop ? (
|
||||
<ProductBackgroundHalftone
|
||||
dashColor={AI_DASH_COLOR}
|
||||
hoverColor={AI_DASH_COLOR}
|
||||
imageUrl={PRODUCT_HERO_BACKGROUND_IMAGE}
|
||||
/>
|
||||
) : null}
|
||||
</PatternOverlay>
|
||||
<ProductVisual
|
||||
activeScene={activeTab + 1}
|
||||
compact
|
||||
panelOnly={isPhone}
|
||||
playbackEnabled={!isDesktop}
|
||||
visual={visual}
|
||||
/>
|
||||
</MobileVisualWrapper>
|
||||
</MobileSection>
|
||||
</MobileRoot>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mobileLayout}
|
||||
<ScrollTrack ref={trackRef}>
|
||||
<StickyFrame>
|
||||
{/* BASE LAYER: INTRO */}
|
||||
<FullLayer
|
||||
ref={setIntroLayerEl}
|
||||
style={{ backgroundColor: '#ffffff', transform: 'translateZ(0)' }}
|
||||
>
|
||||
<StyledContainer>
|
||||
<HeadingGroup>
|
||||
<HeadingSlot style={{ color: theme.colors.primary.text[100] }}>
|
||||
<ContentLayer data-active={true}>{introHeading}</ContentLayer>
|
||||
<ContentLayer data-active={false} style={{ opacity: 0 }}>
|
||||
{aiHeading}
|
||||
</ContentLayer>
|
||||
</HeadingSlot>
|
||||
<BodyText style={{ color: theme.colors.primary.text[60] }}>
|
||||
<ContentLayer data-active={true}>{introBody}</ContentLayer>
|
||||
<ContentLayer data-active={false} style={{ opacity: 0 }}>
|
||||
{aiBody}
|
||||
</ContentLayer>
|
||||
</BodyText>
|
||||
</HeadingGroup>
|
||||
|
||||
<ActionSlot>
|
||||
<CtaLayer data-active={true}>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href={ctaHref}
|
||||
label={ctaLabel}
|
||||
variant="contained"
|
||||
/>
|
||||
{introSecondaryCta}
|
||||
</CtaLayer>
|
||||
<CtaLayer
|
||||
data-active={false}
|
||||
style={{ opacity: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<MeasureTabButtons
|
||||
activeIndex={activeTab}
|
||||
idPrefix="product-hero-intro"
|
||||
onSelect={setActiveTab}
|
||||
tabs={tabs}
|
||||
/>
|
||||
</CtaLayer>
|
||||
</ActionSlot>
|
||||
</StyledContainer>
|
||||
|
||||
<VisualWrapper>
|
||||
<PatternOverlay style={{ opacity: 1.0, zIndex: -1 }}>
|
||||
{isDesktop ? (
|
||||
<ProductBackgroundHalftone
|
||||
dashColor={INTRO_DASH_COLOR}
|
||||
hoverColor={INTRO_DASH_COLOR}
|
||||
imageUrl={PRODUCT_HERO_BACKGROUND_IMAGE}
|
||||
/>
|
||||
) : null}
|
||||
</PatternOverlay>
|
||||
<ProductVisual
|
||||
activeScene={0}
|
||||
collaborative
|
||||
compact
|
||||
cursorActive={morphProgress < 0.5}
|
||||
cursorLayer={introLayerEl}
|
||||
fill
|
||||
playbackEnabled={false}
|
||||
visual={visual}
|
||||
/>
|
||||
</VisualWrapper>
|
||||
</FullLayer>
|
||||
|
||||
{/* TOP LAYER: AI (Wipes up from bottom) */}
|
||||
<FullLayer
|
||||
style={{
|
||||
backgroundColor: '#141414',
|
||||
clipPath: `inset(${100 - morphProgress * 100}% 0 0 0)`,
|
||||
pointerEvents: morphProgress > 0.5 ? 'auto' : 'none',
|
||||
// Prevent text from rendering sub-pixel anti-aliasing differently than the base layer
|
||||
transform: 'translateZ(0)',
|
||||
}}
|
||||
>
|
||||
<StyledContainer>
|
||||
<HeadingGroup>
|
||||
<HeadingSlot
|
||||
style={{ color: theme.colors.secondary.text[100] }}
|
||||
>
|
||||
<ContentLayer data-active={false} style={{ opacity: 0 }}>
|
||||
{introHeading}
|
||||
</ContentLayer>
|
||||
<ContentLayer data-active={true}>{aiHeading}</ContentLayer>
|
||||
</HeadingSlot>
|
||||
<BodyText style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
<ContentLayer data-active={false} style={{ opacity: 0 }}>
|
||||
{introBody}
|
||||
</ContentLayer>
|
||||
<ContentLayer data-active={true}>{aiBody}</ContentLayer>
|
||||
</BodyText>
|
||||
</HeadingGroup>
|
||||
|
||||
<ActionSlot>
|
||||
<CtaLayer
|
||||
data-active={false}
|
||||
style={{ opacity: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href={ctaHref}
|
||||
label={ctaLabel}
|
||||
variant="contained"
|
||||
/>
|
||||
</CtaLayer>
|
||||
<CtaLayer data-active={true}>
|
||||
<MeasureTabButtons
|
||||
activeIndex={activeTab}
|
||||
containerRef={rowButtonsRef}
|
||||
idPrefix="product-hero-ai-measure"
|
||||
onSelect={setActiveTab}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<StackedTabDeck
|
||||
aria-hidden={selectorRevealReady ? undefined : 'true'}
|
||||
style={{
|
||||
...stackStyle,
|
||||
pointerEvents: selectorRevealReady ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{stackCards.map((tab, index) =>
|
||||
(() => {
|
||||
const targetMetric = stackSpreadMetrics?.[index];
|
||||
const width =
|
||||
stackWidth != null && targetMetric != null
|
||||
? stackWidth +
|
||||
(targetMetric.width - stackWidth) *
|
||||
stackSpreadEasedProgress
|
||||
: (stackWidth ?? targetMetric?.width);
|
||||
const offset = targetMetric?.offset ?? 0;
|
||||
|
||||
return (
|
||||
<StackedTabCard
|
||||
key={index}
|
||||
style={{
|
||||
transform: `translate3d(calc(-50% + ${
|
||||
offset * stackSpreadEasedProgress
|
||||
}px), ${
|
||||
stackBaseOffsets[index] *
|
||||
(1 - stackAlignProgress)
|
||||
}px, 0) scale(${
|
||||
stackBaseScales[index] +
|
||||
(1 - stackBaseScales[index]) *
|
||||
stackAlignProgress
|
||||
})`,
|
||||
width: width ? `${width}px` : undefined,
|
||||
zIndex: 4 - index,
|
||||
}}
|
||||
>
|
||||
<TabButton
|
||||
controls={`product-hero-ai-stack-panel-${index}`}
|
||||
id={`product-hero-ai-stack-tab-${index}`}
|
||||
isActive={
|
||||
selectorRevealReady && index === activeTab
|
||||
}
|
||||
onSelect={() => setActiveTab(index)}
|
||||
tab={tab}
|
||||
/>
|
||||
</StackedTabCard>
|
||||
);
|
||||
})(),
|
||||
)}
|
||||
</StackedTabDeck>
|
||||
</CtaLayer>
|
||||
</ActionSlot>
|
||||
</StyledContainer>
|
||||
|
||||
<VisualWrapper>
|
||||
<PatternOverlay style={{ opacity: 1.0, zIndex: -1 }}>
|
||||
{isDesktop ? (
|
||||
<ProductBackgroundHalftone
|
||||
dashColor={AI_DASH_COLOR}
|
||||
hoverColor={AI_DASH_COLOR}
|
||||
imageUrl={PRODUCT_HERO_BACKGROUND_IMAGE}
|
||||
/>
|
||||
) : null}
|
||||
</PatternOverlay>
|
||||
<ProductVisual
|
||||
activeScene={activeTab + 1}
|
||||
aiPanelProgress={aiPanelProgress}
|
||||
compact
|
||||
fill
|
||||
playbackEnabled={aiPlaybackEnabled}
|
||||
visual={visual}
|
||||
/>
|
||||
</VisualWrapper>
|
||||
</FullLayer>
|
||||
</StickyFrame>
|
||||
</ScrollTrack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useRef } from 'react';
|
||||
import { useProductBackgroundHalftone } from './use-product-background-halftone';
|
||||
|
||||
const StyledMount = styled.div<{ $isReady: boolean }>`
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
opacity: ${({ $isReady }) => ($isReady ? 1 : 0)};
|
||||
position: absolute;
|
||||
transition: opacity 600ms ease;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type ProductBackgroundHalftoneProps = {
|
||||
imageUrl: string;
|
||||
dashColor?: string;
|
||||
hoverColor?: string;
|
||||
};
|
||||
|
||||
export function ProductBackgroundHalftone({
|
||||
imageUrl,
|
||||
dashColor,
|
||||
hoverColor,
|
||||
}: ProductBackgroundHalftoneProps) {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
const isReady = useProductBackgroundHalftone({
|
||||
imageUrl,
|
||||
dashColor,
|
||||
hoverColor,
|
||||
mountRef: mountReference,
|
||||
});
|
||||
|
||||
return <StyledMount aria-hidden ref={mountReference} $isReady={isReady} />;
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { MarkerCursor } from '@/sections/ThreeCards/components/FeatureCard/components/MarkerCursor';
|
||||
import { theme } from '@/theme';
|
||||
import type { CursorTarget } from './use-product-hero-cursor-autoplay';
|
||||
|
||||
type Coordinate = { left: number; top: number };
|
||||
|
||||
export type HeroCursorConfig = {
|
||||
color: string;
|
||||
home: Coordinate;
|
||||
// Resting position used on the phone bleed layout, where the window runs off
|
||||
// the right edge; defaults to home when unset. Tune per cursor.
|
||||
mobileHome?: Coordinate;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const HERO_CURSORS: HeroCursorConfig[] = [
|
||||
{
|
||||
name: 'Alice',
|
||||
color: '#ffb08d',
|
||||
home: { left: 13, top: 34 },
|
||||
mobileHome: { left: 13, top: -6 },
|
||||
},
|
||||
{ name: 'Ben', color: '#8db4ff', home: { left: 36, top: 90 } },
|
||||
{ name: 'Cara', color: '#9ee7c5', home: { left: 90, top: 51 } },
|
||||
];
|
||||
|
||||
const GLIDE_BASE_MS = 500;
|
||||
const GLIDE_MS_PER_PX = 0.55;
|
||||
const GLIDE_MIN_MS = 620;
|
||||
const GLIDE_MAX_MS = 1000;
|
||||
const GLIDE_SKIP_PX = 6;
|
||||
|
||||
const ROW_X_OFFSET_PX = 80;
|
||||
const ROW_Y_OFFSET_PX = -5;
|
||||
const RAIL_X_OFFSET_PX = -4;
|
||||
const RAIL_Y_OFFSET_PX = -4;
|
||||
const TAB_X_OFFSET_PX = 2;
|
||||
const TAB_Y_OFFSET_PX = -6;
|
||||
|
||||
function pixelDistance(from: Coordinate, to: Coordinate, rect: DOMRect) {
|
||||
const dx = ((to.left - from.left) / 100) * rect.width;
|
||||
const dy = ((to.top - from.top) / 100) * rect.height;
|
||||
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
function glideForDistance(distance: number) {
|
||||
return Math.max(
|
||||
GLIDE_MIN_MS,
|
||||
Math.min(GLIDE_MAX_MS, GLIDE_BASE_MS + distance * GLIDE_MS_PER_PX),
|
||||
);
|
||||
}
|
||||
|
||||
const Overlay = styled.div`
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
const Marker = styled.div<{
|
||||
$clicking: boolean;
|
||||
$glideMs: number;
|
||||
$hidden: boolean;
|
||||
$left: number;
|
||||
$top: number;
|
||||
}>`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
left: ${({ $left }) => `${$left}%`};
|
||||
opacity: ${({ $hidden }) => ($hidden ? 0 : 1)};
|
||||
position: absolute;
|
||||
top: ${({ $top }) => `${$top}%`};
|
||||
transform: ${({ $clicking }) => ($clicking ? 'scale(0.86)' : 'scale(1)')};
|
||||
transform-origin: top left;
|
||||
transition:
|
||||
left ${({ $glideMs }) => `${$glideMs}ms`} cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top ${({ $glideMs }) => `${$glideMs}ms`} cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 180ms ease,
|
||||
transform 150ms ease;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.span<{ $color: string }>`
|
||||
background: ${({ $color }) => $color};
|
||||
border-radius: 4px;
|
||||
color: #1f1f1f;
|
||||
font-family: ${theme.font.family.mono};
|
||||
font-size: 10px;
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
text-transform: uppercase;
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
const HOME_TARGET: CursorTarget = { kind: 'home' };
|
||||
|
||||
type ProductHeroCursorProps = {
|
||||
clicking: boolean;
|
||||
color: string;
|
||||
glideMs?: number;
|
||||
hidden: boolean;
|
||||
home: Coordinate;
|
||||
name: string;
|
||||
target?: CursorTarget;
|
||||
};
|
||||
|
||||
export function ProductHeroCursor({
|
||||
clicking,
|
||||
color,
|
||||
glideMs: glideMsOverride,
|
||||
hidden,
|
||||
home,
|
||||
name,
|
||||
target = HOME_TARGET,
|
||||
}: ProductHeroCursorProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [coordinate, setCoordinate] = useState<Coordinate>(home);
|
||||
const [glideMs, setGlideMs] = useState(0);
|
||||
const coordinateRef = useRef<Coordinate>(home);
|
||||
|
||||
const moveTo = useCallback((next: Coordinate, explicitMs?: number) => {
|
||||
const overlayRect = overlayRef.current?.getBoundingClientRect();
|
||||
|
||||
if (!overlayRect) {
|
||||
coordinateRef.current = next;
|
||||
setCoordinate(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = pixelDistance(coordinateRef.current, next, overlayRect);
|
||||
|
||||
if (distance < GLIDE_SKIP_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
coordinateRef.current = next;
|
||||
setGlideMs(explicitMs ?? glideForDistance(distance));
|
||||
setCoordinate(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (target.kind === 'home') {
|
||||
moveTo(home);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selector =
|
||||
target.kind === 'row'
|
||||
? `[data-row-id="${target.id}"]`
|
||||
: target.kind === 'rail'
|
||||
? `[data-rail-item-id="${target.id}"]`
|
||||
: `[data-record-tab="${target.id}"]`;
|
||||
|
||||
const measure = () => {
|
||||
const overlay = overlayRef.current;
|
||||
const scene = overlay?.parentElement;
|
||||
|
||||
if (!overlay || !scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = scene.querySelector(selector);
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
if (elementRect.height === 0 || overlayRect.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const xOffset =
|
||||
target.kind === 'row'
|
||||
? ROW_X_OFFSET_PX
|
||||
: target.kind === 'rail'
|
||||
? RAIL_X_OFFSET_PX
|
||||
: TAB_X_OFFSET_PX;
|
||||
const yOffset =
|
||||
target.kind === 'row'
|
||||
? ROW_Y_OFFSET_PX
|
||||
: target.kind === 'rail'
|
||||
? RAIL_Y_OFFSET_PX
|
||||
: TAB_Y_OFFSET_PX;
|
||||
const x =
|
||||
target.kind === 'row'
|
||||
? elementRect.left + xOffset - overlayRect.left
|
||||
: elementRect.left +
|
||||
elementRect.width / 2 +
|
||||
xOffset -
|
||||
overlayRect.left;
|
||||
const y =
|
||||
elementRect.top + elementRect.height / 2 + yOffset - overlayRect.top;
|
||||
|
||||
moveTo(
|
||||
{
|
||||
left: (x / overlayRect.width) * 100,
|
||||
top: (y / overlayRect.height) * 100,
|
||||
},
|
||||
glideMsOverride,
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
|
||||
const settleTimers = [120, 360, 720].map((delay) =>
|
||||
setTimeout(measure, delay),
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', measure);
|
||||
settleTimers.forEach(clearTimeout);
|
||||
};
|
||||
}, [target, home, moveTo, glideMsOverride]);
|
||||
|
||||
return (
|
||||
<Overlay aria-hidden="true" ref={overlayRef}>
|
||||
<Marker
|
||||
$clicking={clicking}
|
||||
$glideMs={glideMs}
|
||||
$hidden={hidden}
|
||||
$left={coordinate.left}
|
||||
$top={coordinate.top}
|
||||
>
|
||||
<MarkerCursor color={color} rotation={132} />
|
||||
<Label $color={color}>{name}</Label>
|
||||
</Marker>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
+391
-304
@@ -1,24 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconChevronDown,
|
||||
IconEdit,
|
||||
IconPaperclip,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import type { AppPreviewConfig } from '@/sections/AppPreview';
|
||||
import { AppWindow } from '@/sections/AppPreview/AppWindow/AppWindow';
|
||||
import { COLORS } from '@/sections/AppPreview/Shared/utils/app-preview-theme';
|
||||
import { AppPreviewFrame } from '@/sections/AppPreview/AppWindow/AppPreviewFrame';
|
||||
import { VISUAL_TOKENS } from '@/sections/AppPreview/Shared/utils/app-preview-tokens';
|
||||
import { AppPreviewNavbar } from '@/sections/AppPreview/Shell/AppPreviewNavbar';
|
||||
import { AppPreviewSidebar } from '@/sections/AppPreview/Shell/AppPreviewSidebar';
|
||||
import { AppPreviewViewbar } from '@/sections/AppPreview/Shell/AppPreviewViewbar';
|
||||
import { renderPageDefinition } from '@/sections/AppPreview/Shell/PageRenderers';
|
||||
import { WindowOrderProvider } from '@/sections/AppPreview/WindowOrder/WindowOrderProvider';
|
||||
import { AppPreviewLayout } from '@/sections/AppPreview/Shell/AppPreviewLayout';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
import { PROMPT_OPTIONS } from './product-visual.data';
|
||||
import { HERO_CURSORS, ProductHeroCursor } from './ProductHeroCursor';
|
||||
import { ProductVisualAiSteps } from './ProductVisualAiSteps';
|
||||
import { ANTHROPIC_RECORD_PAGE } from './product-visual.data';
|
||||
import { sliceVisibleParagraphs } from './streamed-markdown';
|
||||
import { useProductHeroCursorAutoplay } from './use-product-hero-cursor-autoplay';
|
||||
import { useProductVisualAutoplay } from './use-product-visual-autoplay';
|
||||
|
||||
const StyledRoot = styled.div`
|
||||
const MAX_VISIBLE_RESPONSE_CHIPS = 3;
|
||||
|
||||
const StyledRoot = styled.div<{ $fill: boolean }>`
|
||||
display: ${({ $fill }) => ($fill ? 'flex' : 'block')};
|
||||
flex: ${({ $fill }) => ($fill ? '1' : 'none')};
|
||||
flex-direction: column;
|
||||
isolation: isolate;
|
||||
margin-top: ${theme.spacing(5)};
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
@@ -28,76 +43,47 @@ const StyledRoot = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const ShellScene = styled.div`
|
||||
aspect-ratio: 1280 / 832;
|
||||
const WINDOW_MAX_WIDTH = 1040;
|
||||
const WINDOW_HEIGHT = 676;
|
||||
// Phone (<600px) AI experience: the panel-only window is locked to a chat width.
|
||||
const PANEL_ONLY_WIDTH = 320;
|
||||
|
||||
// bleed: fixed-width window that runs off the right edge when the viewport is
|
||||
// narrower than it. compact: same fixed height (no aspect scaling) but the width
|
||||
// fits the viewport (capped), so the board flexes while the sidebar + AI panel
|
||||
// stay legible — used by the morph so the AI panel never bleeds off-screen.
|
||||
// panelOnly: the compact window is capped to a fixed chat width.
|
||||
const ShellScene = styled.div<{
|
||||
$bleed: boolean;
|
||||
$compact: boolean;
|
||||
$panelOnly: boolean;
|
||||
}>`
|
||||
flex: 0 0 auto;
|
||||
height: ${WINDOW_HEIGHT}px;
|
||||
margin: 0 auto;
|
||||
max-height: 740px;
|
||||
max-width: ${({ $compact, $panelOnly }) =>
|
||||
$panelOnly
|
||||
? `${PANEL_ONLY_WIDTH}px`
|
||||
: $compact
|
||||
? `${WINDOW_MAX_WIDTH}px`
|
||||
: 'none'};
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
width: ${({ $bleed }) => ($bleed ? `${WINDOW_MAX_WIDTH}px` : '100%')};
|
||||
`;
|
||||
|
||||
const AppLayout = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const RightColumn = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 12px 12px 12px 0;
|
||||
`;
|
||||
|
||||
const ContentRow = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const IndexSurface = styled.div`
|
||||
background: ${COLORS.background};
|
||||
border: 1px solid ${COLORS.border};
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
[aria-label*='workflow'] > div > div {
|
||||
left: 0;
|
||||
transform: scale(0.65) translateX(-20%);
|
||||
transform-origin: top left;
|
||||
}
|
||||
`;
|
||||
|
||||
const AiPanel = styled.aside`
|
||||
const AiPanel = styled.aside<{ $panelOnly: boolean }>`
|
||||
background: ${VISUAL_TOKENS.background.primary};
|
||||
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
|
||||
border-radius: 8px;
|
||||
border: ${({ $panelOnly }) =>
|
||||
$panelOnly ? 'none' : `1px solid ${VISUAL_TOKENS.border.color.medium}`};
|
||||
border-radius: ${({ $panelOnly }) => ($panelOnly ? '0' : '8px')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 280px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md}px) {
|
||||
display: none;
|
||||
}
|
||||
width: ${({ $panelOnly }) => ($panelOnly ? '100%' : '280px')};
|
||||
`;
|
||||
|
||||
const AiPanelHeader = styled.div`
|
||||
@@ -114,11 +100,11 @@ const AiPanelHeader = styled.div`
|
||||
const AiHeaderBtn = styled.span`
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
color: ${VISUAL_TOKENS.font.color.secondary};
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
display: flex;
|
||||
height: 28px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const AiPanelTitle = styled.span`
|
||||
@@ -126,7 +112,7 @@ const AiPanelTitle = styled.span`
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const AiMessages = styled.div`
|
||||
@@ -139,12 +125,13 @@ const AiMessages = styled.div`
|
||||
`;
|
||||
|
||||
const UserMsg = styled.div`
|
||||
background: ${VISUAL_TOKENS.background.transparent.medium};
|
||||
border-radius: ${VISUAL_TOKENS.border.radius.sm};
|
||||
align-self: flex-end;
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
color: ${VISUAL_TOKENS.font.color.secondary};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
padding: 4px 8px;
|
||||
width: fit-content;
|
||||
`;
|
||||
@@ -153,65 +140,96 @@ const AiMsg = styled.div`
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AiMsgStrong = styled.strong`
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const AiMsgParagraph = styled.div`
|
||||
line-height: inherit;
|
||||
margin-block: 8px;
|
||||
|
||||
&:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const EntityChips = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
const EntityChip = styled.div`
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 3px 6px;
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
const EntityChipIcon = styled.img`
|
||||
border-radius: 2px;
|
||||
height: 14px;
|
||||
object-fit: cover;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const EntityChipName = styled.span`
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const EntityOverflowChip = styled.div`
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
color: ${VISUAL_TOKENS.font.color.secondary};
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
padding: 3px 6px;
|
||||
`;
|
||||
|
||||
const ThinkingText = styled.span`
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const PromptOption = styled.button`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
padding: 6px 4px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: ${VISUAL_TOKENS.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const PromptOptionIcon = styled.span`
|
||||
align-items: center;
|
||||
color: ${VISUAL_TOKENS.font.color.secondary};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const PromptOptions = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const AiInputArea = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
`;
|
||||
|
||||
const AiInputBox = styled.div`
|
||||
background-color: ${VISUAL_TOKENS.background.transparent.lighter};
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
height: 80px;
|
||||
justify-content: space-between;
|
||||
min-height: 32px;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
@@ -219,13 +237,13 @@ const AiInputPlaceholder = styled.span`
|
||||
color: ${VISUAL_TOKENS.font.color.light};
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
padding: 4px 0;
|
||||
`;
|
||||
|
||||
const AiInputBtnRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
`;
|
||||
|
||||
const AiInputLeftBtns = styled.div`
|
||||
@@ -244,12 +262,18 @@ const AiInputRightBtns = styled.div`
|
||||
const ModelChip = styled.span`
|
||||
align-items: center;
|
||||
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
|
||||
border-radius: 6px;
|
||||
color: ${VISUAL_TOKENS.font.color.secondary};
|
||||
border-radius: 4px;
|
||||
color: ${VISUAL_TOKENS.font.color.primary};
|
||||
display: flex;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
padding: 4px 8px;
|
||||
`;
|
||||
|
||||
const ModelChipChevron = styled.span`
|
||||
align-items: center;
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const SendBtn = styled.span`
|
||||
@@ -258,208 +282,271 @@ const SendBtn = styled.span`
|
||||
border-radius: 50%;
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
display: flex;
|
||||
height: 24px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
type ProductVisualProps = {
|
||||
activeScene?: number;
|
||||
aiPanelProgress?: number;
|
||||
bleed?: boolean;
|
||||
collaborative?: boolean;
|
||||
compact?: boolean;
|
||||
compactCursorTour?: boolean;
|
||||
cursorActive?: boolean;
|
||||
cursorLayer?: HTMLElement | null;
|
||||
fill?: boolean;
|
||||
panelOnly?: boolean;
|
||||
playbackEnabled?: boolean;
|
||||
visual: AppPreviewConfig;
|
||||
};
|
||||
|
||||
export function ProductVisual({ activeScene, visual }: ProductVisualProps) {
|
||||
export function ProductVisual({
|
||||
activeScene,
|
||||
aiPanelProgress = 1,
|
||||
bleed = false,
|
||||
collaborative = false,
|
||||
compact = false,
|
||||
compactCursorTour = false,
|
||||
cursorActive = true,
|
||||
cursorLayer,
|
||||
fill = false,
|
||||
panelOnly = false,
|
||||
playbackEnabled = true,
|
||||
visual,
|
||||
}: ProductVisualProps) {
|
||||
const {
|
||||
activeItem,
|
||||
activeLabel,
|
||||
activeItemId,
|
||||
activeItemLabel,
|
||||
activeStepIndex,
|
||||
agentSteps,
|
||||
completedStepCount,
|
||||
displayPage,
|
||||
handleOptionSelect,
|
||||
handleSelectLabel,
|
||||
handleToggleFolder,
|
||||
favorites,
|
||||
highlightedItemId,
|
||||
isScrollDriven,
|
||||
openFolderIds,
|
||||
revealedObjectIds,
|
||||
selectedOption,
|
||||
selectPageItem,
|
||||
selectedScene,
|
||||
streamComplete,
|
||||
streamedText,
|
||||
workspaceNav,
|
||||
} = useProductVisualAutoplay(visual, { externalScene: activeScene });
|
||||
streamedTextVisibleLength,
|
||||
toggleFolder,
|
||||
workspaceEntries,
|
||||
} = useProductVisualAutoplay(visual, {
|
||||
externalScene: activeScene,
|
||||
playbackEnabled,
|
||||
});
|
||||
|
||||
const activeHeader = displayPage?.header;
|
||||
const showViewBar =
|
||||
displayPage != null &&
|
||||
displayPage.type !== 'dashboard' &&
|
||||
displayPage.type !== 'record' &&
|
||||
displayPage.type !== 'workflow';
|
||||
const aiMessagesRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the latest agent step / streamed text in view, like a real chat.
|
||||
useEffect(() => {
|
||||
const messages = aiMessagesRef.current;
|
||||
|
||||
if (messages) {
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
}, [activeStepIndex, completedStepCount, streamedTextVisibleLength]);
|
||||
|
||||
const heroCursor = useProductHeroCursorAutoplay(
|
||||
collaborative && cursorActive,
|
||||
{
|
||||
mobile: compactCursorTour,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (collaborative) {
|
||||
selectPageItem(heroCursor.pageItemId);
|
||||
}
|
||||
}, [collaborative, heroCursor.pageItemId, selectPageItem]);
|
||||
|
||||
const effectivePage =
|
||||
collaborative && heroCursor.showRecord
|
||||
? { ...ANTHROPIC_RECORD_PAGE, activeTabLabel: heroCursor.recordTab }
|
||||
: displayPage;
|
||||
const navbarLabel =
|
||||
effectivePage.type === 'record'
|
||||
? effectivePage.header.title
|
||||
: activeItemLabel;
|
||||
const responseChips = selectedScene.responseChips;
|
||||
const compactWorkflowPage =
|
||||
effectivePage.type === 'workflow' && effectivePage.nodes === undefined;
|
||||
const resolvedDesktopSidebarMode = collaborative
|
||||
? 'collapsed'
|
||||
: (selectedScene.sidebarMode ?? 'collapsed');
|
||||
const visibleResponseChips = responseChips.slice(
|
||||
0,
|
||||
MAX_VISIBLE_RESPONSE_CHIPS,
|
||||
);
|
||||
const hiddenResponseChipCount = Math.max(
|
||||
responseChips.length - MAX_VISIBLE_RESPONSE_CHIPS,
|
||||
0,
|
||||
);
|
||||
|
||||
const previewShell = (
|
||||
<AppPreviewFrame compact={compact} floatingShadow mode="static">
|
||||
<AppPreviewLayout
|
||||
activeItem={activeItem}
|
||||
activeItemId={activeItemId}
|
||||
activeItemLabel={activeItemLabel}
|
||||
asideProgress={aiPanelProgress}
|
||||
compactWorkflowPage={compactWorkflowPage}
|
||||
desktopSidebarMode={resolvedDesktopSidebarMode}
|
||||
favorites={favorites}
|
||||
highlightedItemId={highlightedItemId ?? undefined}
|
||||
navbarLabel={navbarLabel}
|
||||
onSelectPageItem={selectPageItem}
|
||||
onToggleFolder={toggleFolder}
|
||||
openFolderIds={openFolderIds}
|
||||
page={effectivePage}
|
||||
panelOnly={panelOnly}
|
||||
revealedObjectIds={revealedObjectIds}
|
||||
rightAside={
|
||||
collaborative ? undefined : (
|
||||
<AiPanel $panelOnly={panelOnly}>
|
||||
<AiPanelHeader>
|
||||
<AiHeaderBtn>
|
||||
<IconX size={13} stroke={2} />
|
||||
</AiHeaderBtn>
|
||||
<AiPanelTitle>Ask AI</AiPanelTitle>
|
||||
<AiHeaderBtn>
|
||||
<IconEdit size={13} stroke={2} />
|
||||
</AiHeaderBtn>
|
||||
</AiPanelHeader>
|
||||
<AiMessages ref={aiMessagesRef}>
|
||||
<UserMsg>{selectedScene.label}</UserMsg>
|
||||
{agentSteps.length > 0 ? (
|
||||
<ProductVisualAiSteps
|
||||
activeStepIndex={activeStepIndex}
|
||||
answerStarted={streamedTextVisibleLength > 0}
|
||||
completedStepCount={completedStepCount}
|
||||
steps={agentSteps}
|
||||
/>
|
||||
) : null}
|
||||
{streamedTextVisibleLength > 0 ? (
|
||||
<>
|
||||
<AiMsg>
|
||||
{sliceVisibleParagraphs(
|
||||
selectedScene.responseText,
|
||||
streamedTextVisibleLength,
|
||||
).map((segments, paragraphIndex) => (
|
||||
<AiMsgParagraph key={paragraphIndex}>
|
||||
{segments.map((segment, segmentIndex) =>
|
||||
segment.bold ? (
|
||||
<AiMsgStrong key={segmentIndex}>
|
||||
{segment.text}
|
||||
</AiMsgStrong>
|
||||
) : (
|
||||
<Fragment key={segmentIndex}>
|
||||
{segment.text}
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</AiMsgParagraph>
|
||||
))}
|
||||
</AiMsg>
|
||||
{streamComplete && responseChips.length > 0 ? (
|
||||
<EntityChips>
|
||||
{visibleResponseChips.map((chip) => (
|
||||
<EntityChip key={chip.name}>
|
||||
<EntityChipIcon
|
||||
src={chip.logoUrl}
|
||||
alt={chip.name}
|
||||
/>
|
||||
<EntityChipName>{chip.name}</EntityChipName>
|
||||
</EntityChip>
|
||||
))}
|
||||
{hiddenResponseChipCount > 0 ? (
|
||||
<EntityOverflowChip>
|
||||
+{hiddenResponseChipCount} more
|
||||
</EntityOverflowChip>
|
||||
) : null}
|
||||
</EntityChips>
|
||||
) : null}
|
||||
</>
|
||||
) : agentSteps.length === 0 ? (
|
||||
<ThinkingText>Thinking...</ThinkingText>
|
||||
) : null}
|
||||
</AiMessages>
|
||||
<AiInputArea>
|
||||
<AiInputBox>
|
||||
<AiInputPlaceholder>
|
||||
Ask, search or make anything...
|
||||
</AiInputPlaceholder>
|
||||
<AiInputBtnRow>
|
||||
<AiInputLeftBtns>
|
||||
<IconPaperclip size={13} stroke={2} />
|
||||
</AiInputLeftBtns>
|
||||
<AiInputRightBtns>
|
||||
<ModelChip>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#D97757"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
Claude Opus 4.6
|
||||
<ModelChipChevron>
|
||||
<IconChevronDown size={13} stroke={2} />
|
||||
</ModelChipChevron>
|
||||
</ModelChip>
|
||||
<SendBtn>
|
||||
<IconArrowUp size={13} stroke={2} />
|
||||
</SendBtn>
|
||||
</AiInputRightBtns>
|
||||
</AiInputBtnRow>
|
||||
</AiInputBox>
|
||||
</AiInputArea>
|
||||
</AiPanel>
|
||||
)
|
||||
}
|
||||
workspaceEntries={workspaceEntries}
|
||||
/>
|
||||
</AppPreviewFrame>
|
||||
);
|
||||
|
||||
const cursors =
|
||||
collaborative && cursorLayer
|
||||
? createPortal(
|
||||
HERO_CURSORS.map((cursorConfig, index) => {
|
||||
const isActive = index === heroCursor.activeCursor;
|
||||
|
||||
return (
|
||||
<ProductHeroCursor
|
||||
key={cursorConfig.name}
|
||||
clicking={isActive && heroCursor.clicking}
|
||||
color={cursorConfig.color}
|
||||
glideMs={isActive ? heroCursor.glideMs : undefined}
|
||||
hidden={isActive && heroCursor.hidden}
|
||||
home={
|
||||
compactCursorTour && cursorConfig.mobileHome
|
||||
? cursorConfig.mobileHome
|
||||
: cursorConfig.home
|
||||
}
|
||||
name={cursorConfig.name}
|
||||
target={isActive ? heroCursor.target : undefined}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
cursorLayer,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
<ShellScene>
|
||||
<WindowOrderProvider>
|
||||
<AppWindow>
|
||||
<AppLayout>
|
||||
<AppPreviewSidebar
|
||||
favoritesNav={visual.favoritesNav}
|
||||
highlightedItemId={highlightedItemId ?? undefined}
|
||||
onSelectLabel={handleSelectLabel}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
openFolderIds={openFolderIds}
|
||||
selectedLabel={activeLabel}
|
||||
workspaceName={visual.workspace.name}
|
||||
workspaceNav={workspaceNav}
|
||||
/>
|
||||
|
||||
<RightColumn>
|
||||
<AppPreviewNavbar
|
||||
activeItem={activeItem}
|
||||
activeLabel={activeLabel}
|
||||
navbarActions={activeHeader?.navbarActions}
|
||||
revealedObjectIds={revealedObjectIds}
|
||||
/>
|
||||
|
||||
<ContentRow>
|
||||
<IndexSurface>
|
||||
{showViewBar ? (
|
||||
<AppPreviewViewbar
|
||||
actions={activeHeader?.actions ?? []}
|
||||
count={activeHeader?.count}
|
||||
pageType={displayPage.type}
|
||||
showListIcon={activeHeader?.showListIcon ?? false}
|
||||
title={activeHeader?.title ?? activeLabel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{displayPage
|
||||
? renderPageDefinition(
|
||||
displayPage,
|
||||
handleSelectLabel,
|
||||
activeItem?.id ?? activeLabel,
|
||||
)
|
||||
: null}
|
||||
</IndexSurface>
|
||||
|
||||
<AiPanel>
|
||||
<AiPanelHeader>
|
||||
<AiHeaderBtn>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</AiHeaderBtn>
|
||||
<AiPanelTitle>Ask AI</AiPanelTitle>
|
||||
<AiHeaderBtn>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</AiHeaderBtn>
|
||||
</AiPanelHeader>
|
||||
<AiMessages>
|
||||
<UserMsg>{PROMPT_OPTIONS[selectedOption].label}</UserMsg>
|
||||
{streamedText ? (
|
||||
<AiMsg>{streamedText}</AiMsg>
|
||||
) : (
|
||||
<ThinkingText>Thinking...</ThinkingText>
|
||||
)}
|
||||
</AiMessages>
|
||||
{streamComplete && !isScrollDriven ? (
|
||||
<PromptOptions>
|
||||
{PROMPT_OPTIONS.map((option, index) =>
|
||||
index === selectedOption ? null : (
|
||||
<PromptOption
|
||||
key={option.label}
|
||||
onClick={() => handleOptionSelect(index)}
|
||||
>
|
||||
<PromptOptionIcon>{option.icon}</PromptOptionIcon>
|
||||
{option.label}
|
||||
</PromptOption>
|
||||
),
|
||||
)}
|
||||
</PromptOptions>
|
||||
) : null}
|
||||
<AiInputArea>
|
||||
<AiInputBox>
|
||||
<AiInputPlaceholder>
|
||||
Ask, search or make anything...
|
||||
</AiInputPlaceholder>
|
||||
<AiInputBtnRow>
|
||||
<AiInputLeftBtns>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</AiInputLeftBtns>
|
||||
<AiInputRightBtns>
|
||||
<ModelChip>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#D97757"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
Claude Haiku 4.5
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</ModelChip>
|
||||
<SendBtn>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</SendBtn>
|
||||
</AiInputRightBtns>
|
||||
</AiInputBtnRow>
|
||||
</AiInputBox>
|
||||
</AiInputArea>
|
||||
</AiPanel>
|
||||
</ContentRow>
|
||||
</RightColumn>
|
||||
</AppLayout>
|
||||
</AppWindow>
|
||||
</WindowOrderProvider>
|
||||
<StyledRoot $fill={fill}>
|
||||
<ShellScene $bleed={bleed} $compact={compact} $panelOnly={panelOnly}>
|
||||
{previewShell}
|
||||
</ShellScene>
|
||||
{cursors}
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
IconChecklist,
|
||||
IconChevronRight,
|
||||
IconCpu,
|
||||
IconFilter,
|
||||
IconHierarchy3,
|
||||
IconLayoutList,
|
||||
IconMail,
|
||||
IconNotes,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { VISUAL_TOKENS } from '@/sections/AppPreview/Shared/utils/app-preview-tokens';
|
||||
|
||||
import type { AgentStep, AgentToolIcon } from './product-visual.data';
|
||||
|
||||
const TOOL_ICONS: Record<AgentToolIcon, typeof IconSearch> = {
|
||||
search: IconSearch,
|
||||
filter: IconFilter,
|
||||
notes: IconNotes,
|
||||
tasks: IconChecklist,
|
||||
record: IconLayoutList,
|
||||
workflow: IconHierarchy3,
|
||||
mail: IconMail,
|
||||
};
|
||||
|
||||
const StepsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const StepList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const StepRow = styled.div`
|
||||
align-items: center;
|
||||
animation: aiStepAppear 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
|
||||
@keyframes aiStepAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SummaryButton = styled.button`
|
||||
align-items: center;
|
||||
animation: aiSummaryAppear 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: ${VISUAL_TOKENS.font.family};
|
||||
gap: 8px;
|
||||
min-height: 18px;
|
||||
padding: 0;
|
||||
width: fit-content;
|
||||
|
||||
@keyframes aiSummaryAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SummaryChevron = styled.span`
|
||||
align-items: center;
|
||||
color: ${VISUAL_TOKENS.font.color.light};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: transform 150ms ease-in-out;
|
||||
`;
|
||||
|
||||
const SummaryText = styled.span`
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
`;
|
||||
|
||||
const StepIcon = styled.span`
|
||||
align-items: center;
|
||||
color: ${VISUAL_TOKENS.font.color.light};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
min-width: 14px;
|
||||
`;
|
||||
|
||||
const StepLoaderIcon = styled.span`
|
||||
align-items: center;
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
min-width: 14px;
|
||||
`;
|
||||
|
||||
const StepLabel = styled.span`
|
||||
color: ${VISUAL_TOKENS.font.color.tertiary};
|
||||
font-family: ${VISUAL_TOKENS.font.family};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
`;
|
||||
|
||||
function ThinkingOrbitLoader() {
|
||||
return (
|
||||
<StepLoaderIcon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M3.1 7 C3.1 4.4 6.0 4.4 7.0 7 C8.0 9.6 10.9 9.6 10.9 7 C10.9 4.4 8.0 4.4 7.0 7 C6.0 9.6 3.1 9.6 3.1 7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
shapeRendering="geometricPrecision"
|
||||
pathLength={100}
|
||||
strokeDasharray="14 86"
|
||||
strokeDashoffset="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="0;-100"
|
||||
dur="1.05s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
values="10 90;16 84;10 90"
|
||||
dur="1.05s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
</StepLoaderIcon>
|
||||
);
|
||||
}
|
||||
|
||||
type ProductVisualAiStepsProps = {
|
||||
activeStepIndex: number;
|
||||
answerStarted: boolean;
|
||||
completedStepCount: number;
|
||||
steps: AgentStep[];
|
||||
};
|
||||
|
||||
export function ProductVisualAiSteps({
|
||||
activeStepIndex,
|
||||
answerStarted,
|
||||
completedStepCount,
|
||||
steps,
|
||||
}: ProductVisualAiStepsProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isThinking = activeStepIndex >= 0;
|
||||
const shouldKeepExpandedBeforeAnswer = !answerStarted;
|
||||
const shouldShowSummaryButton =
|
||||
!isThinking && !shouldKeepExpandedBeforeAnswer;
|
||||
const shouldRenderRows =
|
||||
isThinking || isExpanded || shouldKeepExpandedBeforeAnswer;
|
||||
|
||||
const stepCount = steps.length;
|
||||
const visibleCount =
|
||||
activeStepIndex >= 0 ? activeStepIndex + 1 : completedStepCount;
|
||||
const visibleSteps = steps.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<StepsContainer>
|
||||
{shouldShowSummaryButton ? (
|
||||
<SummaryButton
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((previousValue) => !previousValue)}
|
||||
>
|
||||
<SummaryChevron
|
||||
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<IconChevronRight size={14} stroke={2} />
|
||||
</SummaryChevron>
|
||||
<SummaryText>
|
||||
{stepCount === 1 ? '1 step' : `${stepCount} steps`}
|
||||
</SummaryText>
|
||||
</SummaryButton>
|
||||
) : null}
|
||||
|
||||
{shouldRenderRows ? (
|
||||
<StepList>
|
||||
{visibleSteps.map((step, index) => {
|
||||
const isRunning = index === activeStepIndex;
|
||||
|
||||
if (step.kind === 'thinking') {
|
||||
return (
|
||||
<StepRow key={`thinking-${index}`}>
|
||||
{isRunning ? (
|
||||
<ThinkingOrbitLoader />
|
||||
) : (
|
||||
<StepIcon>
|
||||
<IconCpu size={14} stroke={2} />
|
||||
</StepIcon>
|
||||
)}
|
||||
<StepLabel>{isRunning ? 'Thinking' : 'Thought'}</StepLabel>
|
||||
</StepRow>
|
||||
);
|
||||
}
|
||||
|
||||
const ToolIcon = TOOL_ICONS[step.icon];
|
||||
|
||||
return (
|
||||
<StepRow key={`tool-${index}`}>
|
||||
<StepIcon>
|
||||
<ToolIcon size={14} stroke={1.8} />
|
||||
</StepIcon>
|
||||
<StepLabel>{isRunning ? step.running : step.done}</StepLabel>
|
||||
</StepRow>
|
||||
);
|
||||
})}
|
||||
</StepList>
|
||||
) : null}
|
||||
</StepsContainer>
|
||||
);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
getVisibleLength,
|
||||
sliceVisibleParagraphs,
|
||||
type MarkdownSegment,
|
||||
} from '../streamed-markdown';
|
||||
|
||||
const visibleCharCount = (paragraphs: MarkdownSegment[][]) =>
|
||||
paragraphs.reduce(
|
||||
(total, segments) =>
|
||||
total + segments.reduce((sum, segment) => sum + segment.text.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
describe('getVisibleLength', () => {
|
||||
it('counts plain characters', () => {
|
||||
expect(getVisibleLength(['hello'])).toBe(5);
|
||||
});
|
||||
|
||||
it('excludes the ** bold markers', () => {
|
||||
expect(getVisibleLength(['a**bold**c'])).toBe(6);
|
||||
expect(getVisibleLength(['**bold**'])).toBe(4);
|
||||
});
|
||||
|
||||
it('still counts text after an unclosed marker', () => {
|
||||
expect(getVisibleLength(['a**b'])).toBe(2);
|
||||
});
|
||||
|
||||
it('sums across paragraphs', () => {
|
||||
expect(getVisibleLength(['ab', 'cd'])).toBe(4);
|
||||
expect(getVisibleLength([''])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sliceVisibleParagraphs', () => {
|
||||
it('returns nothing for a zero-length reveal', () => {
|
||||
expect(sliceVisibleParagraphs(['hello'], 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reveals plain text up to the visible length', () => {
|
||||
expect(sliceVisibleParagraphs(['hello'], 3)).toEqual([
|
||||
[{ bold: false, text: 'hel' }],
|
||||
]);
|
||||
expect(sliceVisibleParagraphs(['hello'], 5)).toEqual([
|
||||
[{ bold: false, text: 'hello' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits plain and bold runs at full length', () => {
|
||||
expect(sliceVisibleParagraphs(['a**bold**c'], 6)).toEqual([
|
||||
[
|
||||
{ bold: false, text: 'a' },
|
||||
{ bold: true, text: 'bold' },
|
||||
{ bold: false, text: 'c' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('truncates inside a bold run', () => {
|
||||
expect(sliceVisibleParagraphs(['a**bold**c'], 3)).toEqual([
|
||||
[
|
||||
{ bold: false, text: 'a' },
|
||||
{ bold: true, text: 'bo' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops before a bold run when the plain prefix fills the budget', () => {
|
||||
expect(sliceVisibleParagraphs(['a**bold**c'], 1)).toEqual([
|
||||
[{ bold: false, text: 'a' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an unclosed marker as a trailing bold run', () => {
|
||||
expect(sliceVisibleParagraphs(['a**b'], 2)).toEqual([
|
||||
[
|
||||
{ bold: false, text: 'a' },
|
||||
{ bold: true, text: 'b' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('flows the budget across paragraphs and drops untouched ones', () => {
|
||||
expect(sliceVisibleParagraphs(['ab', 'cd'], 3)).toEqual([
|
||||
[{ bold: false, text: 'ab' }],
|
||||
[{ bold: false, text: 'c' }],
|
||||
]);
|
||||
expect(sliceVisibleParagraphs(['**x**', 'y'], 1)).toEqual([
|
||||
[{ bold: true, text: 'x' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('never reveals more visible characters than requested or available', () => {
|
||||
const paragraphs = ['Intro **bold** tail', 'second **line**'];
|
||||
const total = getVisibleLength(paragraphs);
|
||||
|
||||
for (let length = 0; length <= total + 5; length += 1) {
|
||||
const revealed = visibleCharCount(
|
||||
sliceVisibleParagraphs(paragraphs, length),
|
||||
);
|
||||
|
||||
expect(revealed).toBe(Math.min(length, total));
|
||||
}
|
||||
});
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import type { MenuScheme, MenuSocialLinkType } from '@/sections/Menu/types';
|
||||
|
||||
type ProductHeroMenuState = {
|
||||
backgroundColor: string;
|
||||
disableElevation: boolean;
|
||||
scheme: MenuScheme;
|
||||
};
|
||||
|
||||
type ProductHeroMenuContextValue = {
|
||||
setMenuState: (state: ProductHeroMenuState) => void;
|
||||
};
|
||||
|
||||
const ProductHeroMenuContext =
|
||||
createContext<ProductHeroMenuContextValue | null>(null);
|
||||
|
||||
export function useProductHeroMenuSync() {
|
||||
return useContext(ProductHeroMenuContext);
|
||||
}
|
||||
|
||||
const INITIAL_MENU_STATE: ProductHeroMenuState = {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0)',
|
||||
disableElevation: true,
|
||||
scheme: 'primary',
|
||||
};
|
||||
|
||||
type ProductHeroMenuSyncProps = {
|
||||
children: ReactNode;
|
||||
socialLinks: MenuSocialLinkType[];
|
||||
};
|
||||
|
||||
export function ProductHeroMenuSync({
|
||||
children,
|
||||
socialLinks,
|
||||
}: ProductHeroMenuSyncProps) {
|
||||
const [menuState, setMenuState] =
|
||||
useState<ProductHeroMenuState>(INITIAL_MENU_STATE);
|
||||
|
||||
const contextValue = useMemo(() => ({ setMenuState }), []);
|
||||
|
||||
return (
|
||||
<ProductHeroMenuContext.Provider value={contextValue}>
|
||||
<Menu
|
||||
backgroundColor={menuState.backgroundColor}
|
||||
disableElevation={menuState.disableElevation}
|
||||
enableBackdropBlur={false}
|
||||
scheme={menuState.scheme}
|
||||
socialLinks={socialLinks}
|
||||
/>
|
||||
{children}
|
||||
</ProductHeroMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
+423
-192
@@ -1,4 +1,7 @@
|
||||
import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
|
||||
import {
|
||||
SHARED_COMPANY_LOGO_URLS,
|
||||
SHARED_PEOPLE_AVATAR_URLS,
|
||||
} from '@/content/site/asset-paths';
|
||||
import type {
|
||||
RecordPageDefinition,
|
||||
RowDef,
|
||||
@@ -27,7 +30,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Anthropic',
|
||||
domain: 'anthropic.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -52,7 +55,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Slack',
|
||||
domain: 'slack.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -77,7 +80,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Figma',
|
||||
domain: 'figma.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -102,7 +105,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Notion',
|
||||
domain: 'notion.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -127,7 +130,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Github',
|
||||
domain: 'github.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,7 +155,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Airbnb',
|
||||
domain: 'airbnb.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -177,7 +180,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Stripe',
|
||||
domain: 'stripe.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -202,7 +205,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Sequoia',
|
||||
domain: 'sequoia.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -227,7 +230,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Accel',
|
||||
domain: 'accel.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -252,225 +255,270 @@ export const NEW_TASK_ROWS: RowDef[] = [
|
||||
name: 'Google',
|
||||
domain: 'google.com',
|
||||
},
|
||||
status: { type: 'tag', value: 'To Do' },
|
||||
status: { type: 'select', value: 'To Do' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const NEW_COMPANY_ROW: RowDef = {
|
||||
id: 'openai',
|
||||
cells: {
|
||||
company: { type: 'entity', name: 'OpenAI', domain: 'openai.com' },
|
||||
url: { type: 'link', value: 'openai.com' },
|
||||
createdBy: {
|
||||
type: 'person',
|
||||
name: 'AI Agent',
|
||||
tone: 'gray',
|
||||
kind: 'system',
|
||||
shortLabel: 'AI',
|
||||
},
|
||||
address: { type: 'text', value: '3180 18th St' },
|
||||
accountOwner: {
|
||||
type: 'person',
|
||||
name: 'Sam Altman',
|
||||
tone: 'amber',
|
||||
kind: 'person',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
|
||||
},
|
||||
icp: { type: 'boolean', value: true },
|
||||
arr: { type: 'number', value: '$2,000,000' },
|
||||
linkedin: { type: 'link', value: 'openai' },
|
||||
industry: { type: 'tag', value: 'AI Research' },
|
||||
mainContact: {
|
||||
type: 'person',
|
||||
name: 'Sam Altman',
|
||||
shortLabel: 'S',
|
||||
tone: 'amber',
|
||||
kind: 'person',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
|
||||
},
|
||||
employees: { type: 'number', value: '3,500' },
|
||||
opportunities: { type: 'relation', items: [] },
|
||||
added: { type: 'text', value: 'Just now' },
|
||||
},
|
||||
export type ResponseChip = {
|
||||
logoUrl: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const NEW_PERSON_ROW: RowDef = {
|
||||
id: 'sam-altman',
|
||||
cells: {
|
||||
name: {
|
||||
type: 'person',
|
||||
name: 'Sam Altman',
|
||||
tone: 'amber',
|
||||
kind: 'person',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
|
||||
},
|
||||
company: { type: 'entity', name: 'OpenAI', domain: 'openai.com' },
|
||||
email: { type: 'link', value: 'sam@openai.com' },
|
||||
phone: { type: 'text', value: '+1 415 555 0199' },
|
||||
jobTitle: { type: 'text', value: 'CEO' },
|
||||
city: { type: 'text', value: 'San Francisco' },
|
||||
linkedin: { type: 'link', value: 'sama' },
|
||||
added: { type: 'text', value: 'Just now' },
|
||||
},
|
||||
export type ProductVisualSceneKind =
|
||||
| 'leadCreation'
|
||||
| 'opportunityReview'
|
||||
| 'taskCreation'
|
||||
| 'dashboardCreation'
|
||||
| 'workflowCreation';
|
||||
|
||||
export type AgentToolIcon =
|
||||
| 'search'
|
||||
| 'filter'
|
||||
| 'notes'
|
||||
| 'tasks'
|
||||
| 'record'
|
||||
| 'workflow'
|
||||
| 'mail';
|
||||
|
||||
export type AgentStep =
|
||||
| { kind: 'thinking'; durationMs: number }
|
||||
| {
|
||||
kind: 'tool';
|
||||
icon: AgentToolIcon;
|
||||
running: string;
|
||||
done: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type ProductVisualSceneDefinition = {
|
||||
initialPageItemId: string;
|
||||
kind: ProductVisualSceneKind;
|
||||
label: string;
|
||||
responseChips: ResponseChip[];
|
||||
responseText: string[];
|
||||
sidebarMode?: 'collapsed' | 'expanded';
|
||||
followUpPageItemId?: string;
|
||||
steps?: AgentStep[];
|
||||
};
|
||||
|
||||
export const PROMPT_OPTIONS = [
|
||||
export const COMPANIES_PAGE_ITEM_ID = 'companies';
|
||||
export const PEOPLE_PAGE_ITEM_ID = 'people';
|
||||
export const OPPORTUNITIES_PAGE_ITEM_ID = 'opportunities';
|
||||
export const TASKS_PAGE_ITEM_ID = 'tasks';
|
||||
export const WORKFLOW_EMAIL_SEQUENCE_PAGE_ITEM_ID =
|
||||
'workflow-send-email-sequence';
|
||||
export const SALES_DASHBOARD_PAGE_ITEM_ID = 'sales-dashboard';
|
||||
|
||||
export const PRODUCT_VISUAL_SCENES: ProductVisualSceneDefinition[] = [
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
),
|
||||
initialPageItemId: COMPANIES_PAGE_ITEM_ID,
|
||||
kind: 'leadCreation',
|
||||
label: 'Add a new lead',
|
||||
navSteps: [
|
||||
{ at: 0.25, target: 'Companies' },
|
||||
{ at: 0.65, target: 'People' },
|
||||
responseText: [],
|
||||
responseChips: [],
|
||||
sidebarMode: 'expanded',
|
||||
},
|
||||
{
|
||||
initialPageItemId: OPPORTUNITIES_PAGE_ITEM_ID,
|
||||
kind: 'opportunityReview',
|
||||
label: 'Build a pipeline board grouped by stage',
|
||||
responseText: [
|
||||
'Organized your open deals into a **pipeline board** grouped by stage — **New**, **Screening**, **Meeting**, **Proposal**, and **Customer**.',
|
||||
'Drag a card to move a deal forward, or open one to see the full history.',
|
||||
],
|
||||
responseChips: [
|
||||
{ name: 'Anthropic', logoUrl: SHARED_COMPANY_LOGO_URLS.anthropic },
|
||||
{ name: 'Notion', logoUrl: SHARED_COMPANY_LOGO_URLS.notion },
|
||||
{ name: 'Github', logoUrl: SHARED_COMPANY_LOGO_URLS.github },
|
||||
{ name: 'Airbnb', logoUrl: SHARED_COMPANY_LOGO_URLS.airbnb },
|
||||
{ name: 'Figma', logoUrl: SHARED_COMPANY_LOGO_URLS.figma },
|
||||
{ name: 'Stripe', logoUrl: SHARED_COMPANY_LOGO_URLS.stripe },
|
||||
{ name: 'Mailchimp', logoUrl: SHARED_COMPANY_LOGO_URLS.mailchimp },
|
||||
],
|
||||
sidebarMode: 'collapsed',
|
||||
steps: [
|
||||
{ kind: 'thinking', durationMs: 1200 },
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'search',
|
||||
running: 'Reading your open deals',
|
||||
done: 'Read 24 deals',
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'record',
|
||||
running: 'Building the pipeline board',
|
||||
done: 'Built the board',
|
||||
durationMs: 800,
|
||||
},
|
||||
],
|
||||
response:
|
||||
'Adding OpenAI as a new company. Setting domain to openai.com, industry to AI Research, and ARR to $2,000,000. Account owner assigned to Sam Altman. Company record is live in your CRM.\n\nNow creating the contact — adding Sam Altman as CEO at OpenAI, based in San Francisco. Person record linked to the company.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Show me all deals closing this month',
|
||||
navSteps: [{ at: 0.3, target: 'Opportunities' }],
|
||||
response:
|
||||
'Filtering your pipeline to deals closing this month. Found 7 opportunities worth $12.9M total across Identified, Qualified, and Engaged stages. The biggest: Host Ops with Airbnb at $4,200,000, followed by AI Prototyping with Figma at $3,500,000.',
|
||||
initialPageItemId: TASKS_PAGE_ITEM_ID,
|
||||
kind: 'taskCreation',
|
||||
label:
|
||||
'Generate follow-up tasks for my top 10 accounts using notes to gather context',
|
||||
responseText: [
|
||||
'Created **10 follow-up tasks** dated **Nov 1 through Nov 8**.',
|
||||
'The first rows cover Anthropic, Slack, Figma, Notion, and Github, followed by Airbnb, Stripe, Sequoia, Accel, and Google.',
|
||||
],
|
||||
responseChips: [
|
||||
{ name: 'Anthropic', logoUrl: SHARED_COMPANY_LOGO_URLS.anthropic },
|
||||
{ name: 'Slack', logoUrl: SHARED_COMPANY_LOGO_URLS.slack },
|
||||
{ name: 'Figma', logoUrl: SHARED_COMPANY_LOGO_URLS.figma },
|
||||
{ name: 'Notion', logoUrl: SHARED_COMPANY_LOGO_URLS.notion },
|
||||
{ name: 'Github', logoUrl: SHARED_COMPANY_LOGO_URLS.github },
|
||||
{ name: 'Airbnb', logoUrl: SHARED_COMPANY_LOGO_URLS.airbnb },
|
||||
{ name: 'Stripe', logoUrl: SHARED_COMPANY_LOGO_URLS.stripe },
|
||||
{ name: 'Sequoia', logoUrl: SHARED_COMPANY_LOGO_URLS.sequoia },
|
||||
{ name: 'Accel', logoUrl: SHARED_COMPANY_LOGO_URLS.accel },
|
||||
{ name: 'Google', logoUrl: SHARED_COMPANY_LOGO_URLS.google },
|
||||
],
|
||||
sidebarMode: 'collapsed',
|
||||
steps: [
|
||||
{ kind: 'thinking', durationMs: 1200 },
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'notes',
|
||||
running: 'Reading notes on your top 10 accounts',
|
||||
done: 'Read 10 accounts',
|
||||
durationMs: 1100,
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'tasks',
|
||||
running: 'Creating 10 follow-up tasks',
|
||||
done: 'Created 10 tasks',
|
||||
durationMs: 900,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="9 11 12 14 22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Create follow-up tasks for my top 10 accounts',
|
||||
navSteps: [{ at: 0.3, target: 'Tasks' }],
|
||||
response:
|
||||
'Creating follow-up tasks for your top 10 accounts by ARR. Done — added 10 tasks:\n\n• "Follow up on Enterprise Expansion" → Anthropic (Nov 1)\n• "Schedule renewal call" → Slack (Nov 2)\n• "Send proposal to Dylan" → Figma (Nov 3)\n• "Review consolidation timeline" → Notion (Nov 4)\n• "Check in on Copilot Rollout" → Github (Nov 5)\n• "Review Host Ops proposal" → Airbnb (Nov 6)\n• "Send billing expansion contract" → Stripe (Nov 6)\n• "Schedule quarterly review" → Sequoia (Nov 7)\n• "Follow up on Portfolio Sync" → Accel (Nov 7)\n• "Prep AI Solutions deck" → Google (Nov 8)\n\nAll assigned to account owners with 7-day deadlines.',
|
||||
initialPageItemId: SALES_DASHBOARD_PAGE_ITEM_ID,
|
||||
kind: 'dashboardCreation',
|
||||
label: 'Build a dashboard of pipeline by stage and ARR',
|
||||
responseText: [
|
||||
'Built a **Sales dashboard** with live KPIs — **$12.9M pipeline**, **$2.4M won this quarter**, and a **38% win rate** — plus charts for deals by stage and ARR over time.',
|
||||
'It refreshes automatically as your data changes.',
|
||||
],
|
||||
responseChips: [],
|
||||
sidebarMode: 'collapsed',
|
||||
steps: [
|
||||
{ kind: 'thinking', durationMs: 1200 },
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'search',
|
||||
running: 'Aggregating pipeline and revenue',
|
||||
done: 'Aggregated 24 deals',
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'record',
|
||||
running: 'Assembling the dashboard',
|
||||
done: 'Built the dashboard',
|
||||
durationMs: 900,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
label: "Summarize this customer's history",
|
||||
navSteps: [{ at: 0.3, target: 'Companies' }],
|
||||
response:
|
||||
"Here's the history for Qonto:\n\nLogged a call between Phil Schiller and Steve Anavi — focused on selling through benefits rather than features. Strategy: emphasize how our CRM streamlines operations and improves customer service.\n\nFollow-up with Alexandre Prot to understand their pain points and position our tool as the solution.\n\n3 notes total, 12 people associated, with Q Global Holdings as parent company. Active opportunity in pipeline.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15 1.65 1.65 0 003 14.08V14a2 2 0 014 0v.09" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Create a workflow that sends an email sequence',
|
||||
navSteps: [{ at: 0.4, target: 'Send email sequence when deal is engaged' }],
|
||||
response:
|
||||
"I built and activated a workflow that sends an email sequence to each one of the selected People.\n\nThis will only send if the Person has emails.primaryEmail filled in. If some People don't have an email, I'll add a filter step to skip sending when the email is empty (to avoid failures).\n\nIf you want to customize the email subject/body (branding, links, etc.), paste your desired text and I'll update the workflow.",
|
||||
initialPageItemId: WORKFLOW_EMAIL_SEQUENCE_PAGE_ITEM_ID,
|
||||
kind: 'workflowCreation',
|
||||
label: 'Draft a workflow that sends an email sequence',
|
||||
responseText: [
|
||||
'Created and activated a sequence with a **Manual trigger**, an **Iterator**, and a **Send Email** step.',
|
||||
'It is ready to run now, and filters or email copy can be refined next.',
|
||||
],
|
||||
responseChips: [],
|
||||
sidebarMode: 'collapsed',
|
||||
steps: [
|
||||
{ kind: 'thinking', durationMs: 1200 },
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'workflow',
|
||||
running: 'Designing the workflow',
|
||||
done: 'Designed 3 steps',
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
icon: 'mail',
|
||||
running: 'Activating the sequence',
|
||||
done: 'Activated sequence',
|
||||
durationMs: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const QONTO_RECORD_PAGE: RecordPageDefinition = {
|
||||
export const ANTHROPIC_RECORD_PAGE: RecordPageDefinition = {
|
||||
type: 'record',
|
||||
header: {
|
||||
title: 'Qonto',
|
||||
title: 'Anthropic',
|
||||
count: 12,
|
||||
},
|
||||
record: {
|
||||
logoDomain: 'qonto.com',
|
||||
name: 'Qonto',
|
||||
logoDomain: 'anthropic.com',
|
||||
name: 'Anthropic',
|
||||
createdAt: 'Created 4 hours ago',
|
||||
fields: [
|
||||
{ icon: 'link', label: 'URL', value: 'qonto.com' },
|
||||
{
|
||||
icon: 'link',
|
||||
label: 'URL',
|
||||
value: { type: 'link', kind: 'url', value: 'anthropic.com' },
|
||||
},
|
||||
{
|
||||
icon: 'user',
|
||||
label: 'Account O...',
|
||||
value: 'Phil Schiller',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.philSchiller,
|
||||
label: 'Account Owner',
|
||||
value: {
|
||||
type: 'person',
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
kind: 'person',
|
||||
tone: 'gray',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'mapPin',
|
||||
label: 'Address',
|
||||
value: '18 Rue De Navarin, 75009 Paris',
|
||||
value: { type: 'text', value: '18 Rue De Navarin' },
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
label: 'ICP',
|
||||
value: { type: 'boolean', value: true },
|
||||
},
|
||||
{
|
||||
icon: 'currency',
|
||||
label: 'ARR',
|
||||
value: { type: 'currency', value: '$500,000' },
|
||||
},
|
||||
{ icon: 'check', label: 'ICP', value: '✓ True' },
|
||||
{ icon: 'currency', label: 'Revenue', value: '$500,000' },
|
||||
{
|
||||
icon: 'linkedin',
|
||||
label: 'Linkedin',
|
||||
value: 'linkedin.com/company/q...',
|
||||
label: 'LinkedIn',
|
||||
value: {
|
||||
type: 'link',
|
||||
kind: 'social',
|
||||
label: 'linkedin.com/company/a...',
|
||||
value: 'anthropic',
|
||||
},
|
||||
},
|
||||
{ icon: 'twitter', label: 'Twitter', value: '@qonto' },
|
||||
],
|
||||
moreCount: 12,
|
||||
relations: [
|
||||
{
|
||||
title: 'Holdings',
|
||||
items: [{ name: 'Q Global Holdings', domain: 'qonto.com' }],
|
||||
},
|
||||
{
|
||||
title: 'Opportunities',
|
||||
items: [{ name: 'Qonto', domain: 'qonto.com' }],
|
||||
items: [{ name: 'Enterprise Expansion', domain: 'anthropic.com' }],
|
||||
},
|
||||
{
|
||||
title: 'People',
|
||||
count: 12,
|
||||
items: [
|
||||
{
|
||||
name: 'Alexandre',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.alexandreProt,
|
||||
},
|
||||
{
|
||||
name: 'Steve Anavi',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.steveAnavi,
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -478,23 +526,206 @@ export const QONTO_RECORD_PAGE: RecordPageDefinition = {
|
||||
},
|
||||
notes: [
|
||||
{
|
||||
id: 'logged-call',
|
||||
title: 'Logged call (Phil Schiller ↔ Steve Anavi)',
|
||||
body: 'Apple sells its products by focusing on the benefits users gain from their products, rather than solely highlighting the features. The same approach should be used for selling to Qonto. Understand their pain points and how your product can alleviate those issues. Emphasize how our CRM tool can help streamline their operations, improve customer service, and ultimately, grow their business.',
|
||||
id: 'kickoff',
|
||||
title: 'Kickoff with Dario',
|
||||
body: 'Walked through the enterprise expansion plan and the security review timeline. Anthropic wants SSO, audit logs, and a dedicated environment before rolling out to the wider research org.',
|
||||
relation: {
|
||||
name: 'Alexandre',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.alexandreProt,
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'follow-up',
|
||||
title: 'Follow-up with Alexandre',
|
||||
body: 'Understand their pain points and how your product can alleviate those issues. Emphasize how our CRM tool can help streamline their operations, improve customer service, and ultimately, grow their business.',
|
||||
id: 'pricing-follow-up',
|
||||
title: 'Follow-up on pricing',
|
||||
body: 'Shared updated seat pricing and the annual commitment options. Next step is a procurement intro and a technical deep-dive with their platform team.',
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
kind: 'calendar',
|
||||
id: 'calendar-security-review',
|
||||
actor: 'Alice',
|
||||
title: 'Security review',
|
||||
detail: 'Tomorrow · 10:00 – 10:45 AM · Dario Amodei, Alice',
|
||||
time: '1 hour ago',
|
||||
},
|
||||
{
|
||||
id: 'third-note',
|
||||
title: 'Third note',
|
||||
body: 'Apple sells its products by focusing on the benefits users gain from their products, rather than solely highlighting the features. The same approach should be used for selling to Qonto. Understand their pain points and how your product can alleviate those issues. Emphasize how our CRM tool can help streamline their operations, improve customer service, and ultimately, grow their business.',
|
||||
kind: 'note',
|
||||
id: 'note-kickoff',
|
||||
actor: 'Alice',
|
||||
title: 'Kickoff with Dario',
|
||||
time: '2 hours ago',
|
||||
},
|
||||
{
|
||||
kind: 'updated',
|
||||
id: 'update-multi',
|
||||
actor: 'Alice',
|
||||
record: 'Anthropic',
|
||||
time: '3 hours ago',
|
||||
diffs: [
|
||||
{ label: 'Industry', value: { type: 'select', value: 'AI Research' } },
|
||||
{ label: 'Employees', value: { type: 'text', value: '612' } },
|
||||
{ label: 'ICP', value: { type: 'boolean', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'updated',
|
||||
id: 'update-arr',
|
||||
actor: 'Alice',
|
||||
record: 'Anthropic',
|
||||
time: '3 hours ago',
|
||||
diffs: [{ label: 'ARR', value: { type: 'currency', value: '$500,000' } }],
|
||||
},
|
||||
{
|
||||
kind: 'created',
|
||||
id: 'record-created',
|
||||
subject: 'Anthropic',
|
||||
actor: 'Dario Amodei',
|
||||
time: '4 hours ago',
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-agreement',
|
||||
title: 'Send enterprise agreement',
|
||||
body: 'Final redlines from legal',
|
||||
due: 'Tomorrow',
|
||||
target: {
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
tone: 'gray',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'task-security',
|
||||
title: 'Complete security review',
|
||||
body: 'SOC 2 and DPA sign-off',
|
||||
due: 'Friday',
|
||||
done: true,
|
||||
target: { name: 'Enterprise Expansion', domain: 'anthropic.com' },
|
||||
},
|
||||
{
|
||||
id: 'task-procurement',
|
||||
title: 'Schedule procurement intro',
|
||||
body: 'Loop in their procurement lead',
|
||||
due: 'Next week',
|
||||
target: { name: 'Alice', tone: 'amber' },
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{
|
||||
id: 'file-agreement',
|
||||
name: 'Enterprise Agreement.pdf',
|
||||
category: 'pdf',
|
||||
date: '2 hours ago',
|
||||
},
|
||||
{
|
||||
id: 'file-security',
|
||||
name: 'Security Review.xlsx',
|
||||
category: 'sheet',
|
||||
date: '1 day ago',
|
||||
},
|
||||
{
|
||||
id: 'file-pricing',
|
||||
name: 'Pricing Proposal.pdf',
|
||||
category: 'pdf',
|
||||
date: '3 days ago',
|
||||
},
|
||||
],
|
||||
emails: [
|
||||
{
|
||||
id: 'email-expansion',
|
||||
participants: [
|
||||
{
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
tone: 'gray',
|
||||
},
|
||||
{ name: 'Alice', tone: 'amber' },
|
||||
],
|
||||
count: 3,
|
||||
subject: 'Re: Enterprise expansion',
|
||||
body: 'Thanks for the detailed proposal — looping in our platform team.',
|
||||
date: '2 hours ago',
|
||||
},
|
||||
{
|
||||
id: 'email-compliance',
|
||||
participants: [
|
||||
{ name: 'Alice', tone: 'amber' },
|
||||
{
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
tone: 'gray',
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
subject: 'Security & compliance docs',
|
||||
body: 'Attaching the SOC 2 report and our DPA for review.',
|
||||
date: '1 day ago',
|
||||
},
|
||||
{
|
||||
id: 'email-procurement',
|
||||
participants: [
|
||||
{
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
tone: 'gray',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
subject: 'Intro to procurement',
|
||||
body: 'Connecting you with our procurement lead to move the contract forward.',
|
||||
date: '2 days ago',
|
||||
},
|
||||
],
|
||||
calendar: [
|
||||
{
|
||||
id: 'cal-day-wed',
|
||||
weekday: 'Wed',
|
||||
day: '12',
|
||||
events: [
|
||||
{
|
||||
id: 'cal-security',
|
||||
start: '10:00',
|
||||
end: '10:45',
|
||||
title: 'Security review',
|
||||
attending: true,
|
||||
participants: [
|
||||
{
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
tone: 'gray',
|
||||
},
|
||||
{ name: 'Alice', tone: 'amber' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cal-day-fri',
|
||||
weekday: 'Fri',
|
||||
day: '14',
|
||||
events: [
|
||||
{
|
||||
id: 'cal-qbr',
|
||||
start: '14:00',
|
||||
end: '15:00',
|
||||
title: 'Quarterly business review',
|
||||
participants: [
|
||||
{
|
||||
name: 'Dario Amodei',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
|
||||
tone: 'gray',
|
||||
},
|
||||
{ name: 'Alice', tone: 'amber' },
|
||||
{
|
||||
name: 'Marcus Lee',
|
||||
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.anonymousMike,
|
||||
tone: 'amber',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// Incremental "**bold**" rendering for the streamed AI answer. The stream target
|
||||
// (how many characters to reveal) and the render (which characters are shown)
|
||||
// both depend on one definition of a "visible character" — anything except the
|
||||
// `**` markers — so the two can never drift out of sync.
|
||||
|
||||
export type MarkdownSegment = {
|
||||
bold: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
// Rendered character count, excluding the `**` bold markers.
|
||||
export function getVisibleLength(paragraphs: string[]): number {
|
||||
return paragraphs.reduce(
|
||||
(total, paragraph) => total + getParagraphVisibleLength(paragraph),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function getParagraphVisibleLength(text: string): number {
|
||||
let length = 0;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text[index] === '*' && text[index + 1] === '*') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
length += 1;
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
// Reveal the first `visibleLength` visible characters across `paragraphs`,
|
||||
// returning each non-empty paragraph as a run of bold/plain segments.
|
||||
export function sliceVisibleParagraphs(
|
||||
paragraphs: string[],
|
||||
visibleLength: number,
|
||||
): MarkdownSegment[][] {
|
||||
const result: MarkdownSegment[][] = [];
|
||||
let visibleRemaining = visibleLength;
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (visibleRemaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { consumed, segments } = sliceParagraph(paragraph, visibleRemaining);
|
||||
|
||||
if (segments.length > 0) {
|
||||
result.push(segments);
|
||||
}
|
||||
|
||||
visibleRemaining -= consumed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function sliceParagraph(
|
||||
text: string,
|
||||
visibleLength: number,
|
||||
): { consumed: number; segments: MarkdownSegment[] } {
|
||||
const segments: MarkdownSegment[] = [];
|
||||
let visibleRemaining = visibleLength;
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length && visibleRemaining > 0) {
|
||||
const openIndex = text.indexOf('**', cursor);
|
||||
|
||||
if (openIndex === -1) {
|
||||
const segment = text.slice(cursor, cursor + visibleRemaining);
|
||||
segments.push({ bold: false, text: segment });
|
||||
visibleRemaining -= segment.length;
|
||||
break;
|
||||
}
|
||||
|
||||
if (openIndex > cursor) {
|
||||
const plainSegment = text.slice(cursor, openIndex);
|
||||
const visiblePlainSegment = plainSegment.slice(0, visibleRemaining);
|
||||
|
||||
if (visiblePlainSegment.length > 0) {
|
||||
segments.push({ bold: false, text: visiblePlainSegment });
|
||||
visibleRemaining -= visiblePlainSegment.length;
|
||||
}
|
||||
|
||||
if (visiblePlainSegment.length < plainSegment.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const closeIndex = text.indexOf('**', openIndex + 2);
|
||||
|
||||
if (closeIndex === -1) {
|
||||
const trailingSegment = text.slice(
|
||||
openIndex + 2,
|
||||
openIndex + 2 + visibleRemaining,
|
||||
);
|
||||
|
||||
if (trailingSegment.length > 0) {
|
||||
segments.push({ bold: true, text: trailingSegment });
|
||||
visibleRemaining -= trailingSegment.length;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const boldSegment = text.slice(openIndex + 2, closeIndex);
|
||||
const visibleBoldSegment = boldSegment.slice(0, visibleRemaining);
|
||||
|
||||
// Skip an empty run when the budget ends exactly at the marker (the old
|
||||
// inline renderer emitted an invisible empty <strong> here).
|
||||
if (visibleBoldSegment.length > 0) {
|
||||
segments.push({ bold: true, text: visibleBoldSegment });
|
||||
}
|
||||
|
||||
visibleRemaining -= visibleBoldSegment.length;
|
||||
|
||||
if (visibleBoldSegment.length < boldSegment.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = closeIndex + 2;
|
||||
}
|
||||
|
||||
return { consumed: visibleLength - visibleRemaining, segments };
|
||||
}
|
||||
+84
-19
@@ -1,36 +1,101 @@
|
||||
import { type RefObject, useEffect, useState } from 'react';
|
||||
'use client';
|
||||
|
||||
const TRANSITION_POINT = 0.25;
|
||||
const MOBILE_BREAKPOINT = 921;
|
||||
import { type RefObject, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const NAV_HEIGHT = 64;
|
||||
|
||||
const MORPH_START = 0;
|
||||
const MORPH_END = 0.55;
|
||||
|
||||
function smoothstep(value: number): number {
|
||||
return value * value * (3 - 2 * value);
|
||||
}
|
||||
|
||||
function mapToMorphProgress(scrollProgress: number): number {
|
||||
if (scrollProgress <= MORPH_START) return 0;
|
||||
if (scrollProgress >= MORPH_END) return 1;
|
||||
|
||||
const linear = (scrollProgress - MORPH_START) / (MORPH_END - MORPH_START);
|
||||
|
||||
return smoothstep(linear);
|
||||
}
|
||||
|
||||
export type HeroScrollState = {
|
||||
menuBackground: string;
|
||||
menuElevated: boolean;
|
||||
morphProgress: number;
|
||||
navProgress: number;
|
||||
};
|
||||
|
||||
const INITIAL_STATE: HeroScrollState = {
|
||||
menuBackground: 'rgb(255, 255, 255)',
|
||||
menuElevated: true,
|
||||
morphProgress: 0,
|
||||
navProgress: 0,
|
||||
};
|
||||
|
||||
export function useHeroScrollProgress(
|
||||
trackRef: RefObject<HTMLDivElement | null>,
|
||||
): number {
|
||||
const [phase, setPhase] = useState(0);
|
||||
): HeroScrollState {
|
||||
const [state, setState] = useState<HeroScrollState>(INITIAL_STATE);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = useCallback(() => {
|
||||
const element = trackRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
if (window.innerWidth < MOBILE_BREAKPOINT) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scrollableDistance = element.offsetHeight - window.innerHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scrollableDistance = element.offsetHeight - window.innerHeight;
|
||||
if (scrollableDistance <= 0) return;
|
||||
|
||||
if (scrollableDistance <= 0) return;
|
||||
const scrolled = -rect.top;
|
||||
const progress = Math.max(0, Math.min(1, scrolled / scrollableDistance));
|
||||
const morphProgress = mapToMorphProgress(progress);
|
||||
|
||||
const scrolled = -rect.top;
|
||||
const progress = Math.max(0, Math.min(1, scrolled / scrollableDistance));
|
||||
let navProgress = 0;
|
||||
|
||||
setPhase(progress >= TRANSITION_POINT ? 1 : 0);
|
||||
};
|
||||
const wipeLineY = window.innerHeight * (1 - morphProgress);
|
||||
const trackBottom = rect.bottom;
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
if (wipeLineY <= 0) {
|
||||
navProgress = 1;
|
||||
} else if (wipeLineY < NAV_HEIGHT) {
|
||||
navProgress = smoothstep(1 - wipeLineY / NAV_HEIGHT);
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
if (trackBottom <= 0) {
|
||||
navProgress = 0;
|
||||
} else if (trackBottom < NAV_HEIGHT) {
|
||||
navProgress *= smoothstep(trackBottom / NAV_HEIGHT);
|
||||
}
|
||||
|
||||
const isCrossing =
|
||||
morphProgress < 1 && wipeLineY <= NAV_HEIGHT && trackBottom > NAV_HEIGHT;
|
||||
|
||||
const channel = Math.round(255 + (20 - 255) * navProgress);
|
||||
const menuBackground = isCrossing
|
||||
? 'transparent'
|
||||
: `rgb(${channel}, ${channel}, ${channel})`;
|
||||
|
||||
setState({
|
||||
menuBackground,
|
||||
menuElevated: navProgress < 0.02 && !isCrossing,
|
||||
morphProgress,
|
||||
navProgress,
|
||||
});
|
||||
}, [trackRef]);
|
||||
|
||||
return phase;
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
'use client';
|
||||
|
||||
import { createAnimationFrameLoop } from '@/lib/animation';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoop,
|
||||
type VisualRenderLoopFrame,
|
||||
} from '@/lib/visual-runtime';
|
||||
import { useEffect, useState, type RefObject } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const VIRTUAL_RENDER_HEIGHT = 800;
|
||||
|
||||
const HALFTONE_TILE_SIZE = 8;
|
||||
const HALFTONE_POWER = 0.1;
|
||||
const HALFTONE_WIDTH = 0.52;
|
||||
const HALFTONE_CONTRAST = 0.95;
|
||||
const HALFTONE_DASH_COLOR = '#ffffff';
|
||||
const HALFTONE_HOVER_COLOR = '#ffffff';
|
||||
const HALFTONE_HOVER_LIGHT_INTENSITY = 0.8;
|
||||
const HALFTONE_HOVER_LIGHT_RADIUS = 0.14;
|
||||
const HALFTONE_HOVER_VERTICAL_FADE = 0.5;
|
||||
const HALFTONE_HOVER_FADE_IN = 18;
|
||||
const HALFTONE_HOVER_FADE_OUT = 7;
|
||||
|
||||
const IMAGE_POINTER_FOLLOW = 0.38;
|
||||
|
||||
const passThroughVertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float contrast;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv - 0.5;
|
||||
float coverRatio = imageAspect / viewAspect;
|
||||
if (coverRatio > 1.0) {
|
||||
uv.x /= coverRatio;
|
||||
} else {
|
||||
uv.y *= coverRatio;
|
||||
}
|
||||
uv += 0.5;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(contrastColor, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform vec2 effectResolution;
|
||||
uniform vec2 logicalResolution;
|
||||
uniform float tile;
|
||||
uniform float s_3;
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform vec3 hoverDashColor;
|
||||
uniform vec2 interactionUv;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
uniform float hoverVerticalFade;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float denom = max(dot(ba, ba), 0.000001);
|
||||
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
|
||||
return length(pa - ba * h);
|
||||
}
|
||||
|
||||
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
|
||||
vec2 a = vec2(0.5) + vec2(-r, 0.0);
|
||||
vec2 b = vec2(0.5) + vec2(r, 0.0);
|
||||
float distToSegment = distSegment(p, a, b);
|
||||
float halfThickness = thickness * r;
|
||||
return distToSegment - halfThickness;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Only draw within the image footprint.
|
||||
vec4 boundsCheck = texture2D(tScene, vUv);
|
||||
if (boundsCheck.a < 0.01) {
|
||||
gl_FragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
vec2 fragCoord =
|
||||
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
|
||||
float halftoneSize = max(tile, 1.0);
|
||||
vec2 pointerPx = interactionUv * logicalResolution;
|
||||
vec2 fragDelta = fragCoord - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
float fadeRange = max(hoverVerticalFade, 0.0001);
|
||||
float verticalHoverFade =
|
||||
smoothstep(0.0, fadeRange, vUv.y) *
|
||||
smoothstep(0.0, fadeRange, 1.0 - vUv.y);
|
||||
hoverLightMask *= verticalHoverFade;
|
||||
}
|
||||
|
||||
vec2 cellIndex = floor(fragCoord / halftoneSize);
|
||||
vec2 sampleUv = clamp(
|
||||
(cellIndex + 0.5) * halftoneSize / logicalResolution,
|
||||
vec2(0.0),
|
||||
vec2(1.0)
|
||||
);
|
||||
vec2 cellUv = fract(fragCoord / halftoneSize);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float localPower = clamp(s_3, -1.5, 1.5);
|
||||
float localWidth = clamp(s_4, 0.05, 1.4);
|
||||
float lightLift = hoverLightStrength * hoverLightMask * 0.22;
|
||||
float toneValue =
|
||||
(sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);
|
||||
float bandRadius = clamp(
|
||||
toneValue + localPower * length(vec2(0.5)) * (1.0 / 3.0) + lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
|
||||
float alpha = 0.0;
|
||||
if (bandRadius > 0.0001) {
|
||||
float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);
|
||||
float edge = 0.02;
|
||||
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
|
||||
}
|
||||
|
||||
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverLightMask);
|
||||
vec3 color = activeDashColor * alpha;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
format: THREE.RGBAFormat,
|
||||
magFilter: THREE.LinearFilter,
|
||||
minFilter: THREE.LinearFilter,
|
||||
});
|
||||
}
|
||||
|
||||
type PointerState = {
|
||||
hoverStrength: number;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerInside: boolean;
|
||||
smoothedMouseX: number;
|
||||
smoothedMouseY: number;
|
||||
};
|
||||
|
||||
async function mountProductBackgroundCanvas({
|
||||
container,
|
||||
imageUrl,
|
||||
dashColor = HALFTONE_DASH_COLOR,
|
||||
hoverColor = HALFTONE_HOVER_COLOR,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
imageUrl: string;
|
||||
dashColor?: string;
|
||||
hoverColor?: string;
|
||||
}): Promise<() => void> {
|
||||
const image = await loadVisualImage(imageUrl, {
|
||||
label: 'product background image',
|
||||
});
|
||||
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
let renderLoop: VisualRenderLoop | null = null;
|
||||
const renderer = tryCreateSiteWebGlRenderer({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
onContextLost: () => {
|
||||
renderLoop?.stop();
|
||||
},
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
|
||||
if (renderer === null) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.generateMipmaps = false;
|
||||
imageTexture.magFilter = THREE.LinearFilter;
|
||||
imageTexture.minFilter = THREE.LinearFilter;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
uniforms: {
|
||||
contrast: { value: HALFTONE_CONTRAST },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
tImage: { value: imageTexture },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
dashColor: { value: new THREE.Color(dashColor) },
|
||||
effectResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
hoverDashColor: { value: new THREE.Color(hoverColor) },
|
||||
hoverLightRadius: { value: HALFTONE_HOVER_LIGHT_RADIUS },
|
||||
hoverLightStrength: { value: 0 },
|
||||
hoverVerticalFade: { value: HALFTONE_HOVER_VERTICAL_FADE },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
s_3: { value: HALFTONE_POWER },
|
||||
s_4: { value: HALFTONE_WIDTH },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tile: { value: HALFTONE_TILE_SIZE },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
halftoneMaterial.uniforms.effectResolution.value.set(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
halftoneMaterial.uniforms.logicalResolution.value.set(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
imageMaterial.uniforms.viewportSize.value.set(virtualWidth, virtualHeight);
|
||||
};
|
||||
|
||||
const stopObservingSize = observeElementSize(container, syncSize);
|
||||
|
||||
const pointer: PointerState = {
|
||||
hoverStrength: 0,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerInside: false,
|
||||
smoothedMouseX: 0.5,
|
||||
smoothedMouseY: 0.5,
|
||||
};
|
||||
|
||||
const updatePointerPosition = (event: PointerEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
|
||||
pointer.mouseX = (event.clientX - rect.left) / width;
|
||||
pointer.mouseY = (event.clientY - rect.top) / height;
|
||||
pointer.pointerInside = true;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
pointer.pointerInside = false;
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('blur', handlePointerLeave);
|
||||
|
||||
const renderFrame = (
|
||||
_timestamp: DOMHighResTimeStamp,
|
||||
{ deltaSeconds }: VisualRenderLoopFrame,
|
||||
) => {
|
||||
const hoverEasing =
|
||||
1 -
|
||||
Math.exp(
|
||||
-deltaSeconds *
|
||||
(pointer.pointerInside
|
||||
? HALFTONE_HOVER_FADE_IN
|
||||
: HALFTONE_HOVER_FADE_OUT),
|
||||
);
|
||||
pointer.hoverStrength +=
|
||||
((pointer.pointerInside ? 1 : 0) - pointer.hoverStrength) * hoverEasing;
|
||||
|
||||
pointer.smoothedMouseX +=
|
||||
(pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
|
||||
pointer.smoothedMouseY +=
|
||||
(pointer.mouseY - pointer.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
|
||||
|
||||
halftoneMaterial.uniforms.interactionUv.value.set(
|
||||
pointer.smoothedMouseX,
|
||||
1 - pointer.smoothedMouseY,
|
||||
);
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value =
|
||||
HALFTONE_HOVER_LIGHT_INTENSITY * pointer.hoverStrength;
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
renderLoop = createVisualRenderLoop({
|
||||
renderFrame,
|
||||
target: container,
|
||||
targetVisibilityOptions: { rootMargin: '100px' },
|
||||
});
|
||||
renderLoop.start();
|
||||
|
||||
return () => {
|
||||
renderLoop?.dispose();
|
||||
stopObservingSize();
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('blur', handlePointerLeave);
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useProductBackgroundHalftone({
|
||||
imageUrl,
|
||||
dashColor,
|
||||
hoverColor,
|
||||
mountRef,
|
||||
}: {
|
||||
imageUrl: string;
|
||||
dashColor?: string;
|
||||
hoverColor?: string;
|
||||
mountRef: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unmount: (() => void) | null = null;
|
||||
const readyTask = createAnimationFrameLoop({
|
||||
onFrame: () => {
|
||||
setIsReady(true);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
mountProductBackgroundCanvas({
|
||||
container,
|
||||
imageUrl,
|
||||
dashColor,
|
||||
hoverColor,
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (disposed) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
unmount = dispose;
|
||||
readyTask.start();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
readyTask.stop();
|
||||
unmount?.();
|
||||
};
|
||||
}, [mountRef, imageUrl, dashColor, hoverColor]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type CursorTarget =
|
||||
| { kind: 'home' }
|
||||
| { kind: 'row'; id: string }
|
||||
| { kind: 'rail'; id: string }
|
||||
| { kind: 'tab'; id: string };
|
||||
|
||||
export type ProductHeroTourState = {
|
||||
activeCursor: number;
|
||||
clicking: boolean;
|
||||
glideMs?: number;
|
||||
hidden: boolean;
|
||||
pageItemId: string;
|
||||
recordTab?: string;
|
||||
showRecord: boolean;
|
||||
target: CursorTarget;
|
||||
};
|
||||
|
||||
type TourPhase = {
|
||||
activeCursor: number;
|
||||
clicking: boolean;
|
||||
durationMs: number;
|
||||
glideMs?: number;
|
||||
hidden: boolean;
|
||||
pageItemId: string;
|
||||
recordTab?: string;
|
||||
showRecord: boolean;
|
||||
target: CursorTarget;
|
||||
};
|
||||
|
||||
const COMPANIES = 'companies';
|
||||
const PEOPLE = 'people';
|
||||
const NOTES = 'notes';
|
||||
const RECORD_ROW_ID = 'anthropic';
|
||||
|
||||
const LOOP_START_INDEX = 0;
|
||||
|
||||
// Desktop tour — explores the record's Timeline/Notes/Calendar tabs.
|
||||
const DESKTOP_PHASES: TourPhase[] = [
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'home' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1400,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'row', id: RECORD_ROW_ID },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'row', id: RECORD_ROW_ID },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 400,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Timeline',
|
||||
target: { kind: 'home' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 800,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Timeline',
|
||||
target: { kind: 'tab', id: 'Notes' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 900,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Notes',
|
||||
target: { kind: 'tab', id: 'Notes' },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 450,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Notes',
|
||||
target: { kind: 'home' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 800,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Notes',
|
||||
target: { kind: 'tab', id: 'Calendar' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 750,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Calendar',
|
||||
target: { kind: 'tab', id: 'Calendar' },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 700,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Calendar',
|
||||
target: { kind: 'rail', id: PEOPLE },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 700,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: PEOPLE,
|
||||
showRecord: true,
|
||||
recordTab: 'Calendar',
|
||||
target: { kind: 'rail', id: PEOPLE },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 300,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: PEOPLE,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: PEOPLE },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 900,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: PEOPLE,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: NOTES },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 800,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: NOTES,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: NOTES },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 300,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: NOTES,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: NOTES },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 600,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: NOTES,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: COMPANIES },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1100,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: COMPANIES },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 300,
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile tour — the three cursors strictly alternate (Alice -> Ben -> Cara) and
|
||||
// only touch elements that fit a narrow window: the Companies list, the record
|
||||
// Timeline tab, and the sidebar rails. No off-screen record Notes/Calendar tabs.
|
||||
const MOBILE_PHASES: TourPhase[] = [
|
||||
// Alice opens the Anthropic record.
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'home' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1400,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'row', id: RECORD_ROW_ID },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
activeCursor: 0,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'row', id: RECORD_ROW_ID },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 400,
|
||||
},
|
||||
// Ben reads the record, then navigates to People.
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Timeline',
|
||||
target: { kind: 'home' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: true,
|
||||
recordTab: 'Timeline',
|
||||
target: { kind: 'rail', id: PEOPLE },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 900,
|
||||
},
|
||||
{
|
||||
activeCursor: 1,
|
||||
pageItemId: PEOPLE,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: PEOPLE },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 400,
|
||||
},
|
||||
// Cara browses People, then loops back to Companies.
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: PEOPLE,
|
||||
showRecord: false,
|
||||
target: { kind: 'home' },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 1000,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: PEOPLE,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: COMPANIES },
|
||||
clicking: false,
|
||||
hidden: false,
|
||||
durationMs: 900,
|
||||
},
|
||||
{
|
||||
activeCursor: 2,
|
||||
pageItemId: COMPANIES,
|
||||
showRecord: false,
|
||||
target: { kind: 'rail', id: COMPANIES },
|
||||
clicking: true,
|
||||
hidden: false,
|
||||
durationMs: 400,
|
||||
},
|
||||
];
|
||||
|
||||
export function useProductHeroCursorAutoplay(
|
||||
enabled: boolean,
|
||||
options: { mobile?: boolean } = {},
|
||||
): ProductHeroTourState {
|
||||
const { mobile = false } = options;
|
||||
const phases = mobile ? MOBILE_PHASES : DESKTOP_PHASES;
|
||||
|
||||
const [phaseIndex, setPhaseIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setPhaseIndex(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setPhaseIndex((current) =>
|
||||
current >= phases.length - 1 ? LOOP_START_INDEX : current + 1,
|
||||
);
|
||||
}, phases[phaseIndex].durationMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [enabled, phaseIndex, phases]);
|
||||
|
||||
const phase = phases[phaseIndex];
|
||||
|
||||
return {
|
||||
activeCursor: phase.activeCursor,
|
||||
clicking: phase.clicking,
|
||||
glideMs: phase.glideMs,
|
||||
hidden: phase.hidden,
|
||||
pageItemId: phase.pageItemId,
|
||||
recordTab: phase.recordTab,
|
||||
showRecord: phase.showRecord,
|
||||
target: phase.target,
|
||||
};
|
||||
}
|
||||
+229
-133
@@ -1,177 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AppPreviewConfig } from '@/sections/AppPreview';
|
||||
import { useAppPreviewState } from '@/sections/AppPreview/Shell/use-app-preview-state';
|
||||
import { useAppPreviewExperience } from '@/sections/AppPreview/Shell/use-app-preview-experience';
|
||||
import type {
|
||||
KanbanPageDefinition,
|
||||
PageDefinition,
|
||||
} from '@/sections/AppPreview/types/app-preview-data';
|
||||
|
||||
import {
|
||||
NEW_COMPANY_ROW,
|
||||
NEW_PERSON_ROW,
|
||||
NEW_TASK_ROWS,
|
||||
PROMPT_OPTIONS,
|
||||
QONTO_RECORD_PAGE,
|
||||
PRODUCT_VISUAL_SCENES,
|
||||
TASKS_PAGE_ITEM_ID,
|
||||
type ProductVisualSceneDefinition,
|
||||
} from './product-visual.data';
|
||||
import { getVisibleLength } from './streamed-markdown';
|
||||
|
||||
type AutoplayOptions = {
|
||||
externalScene?: number;
|
||||
playbackEnabled?: boolean;
|
||||
};
|
||||
|
||||
function getStreamProgress(streamedLength: number, fullTextLength: number) {
|
||||
if (fullTextLength === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(streamedLength / fullTextLength, 1);
|
||||
}
|
||||
|
||||
function clamp(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function buildFocusedOpportunitiesPage(
|
||||
page: KanbanPageDefinition,
|
||||
streamProgress: number,
|
||||
): KanbanPageDefinition {
|
||||
const filteredLanes = page.lanes.filter((lane) => lane.cards.length > 0);
|
||||
const revealProgress = clamp((streamProgress - 0.18) / 0.62);
|
||||
const totalCardCount = filteredLanes.reduce(
|
||||
(total, lane) => total + lane.cards.length,
|
||||
0,
|
||||
);
|
||||
const visibleCardCount =
|
||||
revealProgress <= 0
|
||||
? 0
|
||||
: Math.min(totalCardCount, Math.ceil(revealProgress * totalCardCount));
|
||||
let remainingVisibleCards = visibleCardCount;
|
||||
|
||||
const lanes = filteredLanes.map((lane) => {
|
||||
const laneVisibleCount = Math.max(
|
||||
0,
|
||||
Math.min(lane.cards.length, remainingVisibleCards),
|
||||
);
|
||||
|
||||
remainingVisibleCards -= laneVisibleCount;
|
||||
|
||||
return {
|
||||
...lane,
|
||||
cards: lane.cards.slice(0, laneVisibleCount),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...page,
|
||||
header: {
|
||||
...page.header,
|
||||
count: visibleCardCount,
|
||||
title: 'Pipeline by stage',
|
||||
},
|
||||
lanes,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDisplayPage(
|
||||
activePage: PageDefinition,
|
||||
activeItemId: string,
|
||||
scene: ProductVisualSceneDefinition,
|
||||
streamProgress: number,
|
||||
): PageDefinition {
|
||||
if (scene.kind === 'opportunityReview' && activePage.type === 'kanban') {
|
||||
const generating = streamProgress === 0;
|
||||
const focusedPage = buildFocusedOpportunitiesPage(activePage, 1);
|
||||
|
||||
return {
|
||||
...focusedPage,
|
||||
generating,
|
||||
header: {
|
||||
...focusedPage.header,
|
||||
count: generating ? undefined : focusedPage.header.count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (scene.kind === 'dashboardCreation' && activePage.type === 'dashboard') {
|
||||
return {
|
||||
...activePage,
|
||||
dashboard: {
|
||||
...activePage.dashboard,
|
||||
generating: streamProgress === 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
scene.kind === 'taskCreation' &&
|
||||
activeItemId === TASKS_PAGE_ITEM_ID &&
|
||||
activePage.type === 'table'
|
||||
) {
|
||||
const generating = streamProgress === 0;
|
||||
|
||||
return {
|
||||
...activePage,
|
||||
generating,
|
||||
header: {
|
||||
...activePage.header,
|
||||
count: generating ? undefined : NEW_TASK_ROWS.length,
|
||||
},
|
||||
rows: NEW_TASK_ROWS,
|
||||
};
|
||||
}
|
||||
|
||||
if (scene.kind === 'workflowCreation' && activePage.type === 'workflow') {
|
||||
return {
|
||||
...activePage,
|
||||
generating: streamProgress === 0,
|
||||
};
|
||||
}
|
||||
|
||||
return activePage;
|
||||
}
|
||||
|
||||
export function useProductVisualAutoplay(
|
||||
visual: AppPreviewConfig,
|
||||
options: AutoplayOptions = {},
|
||||
) {
|
||||
const { externalScene } = options;
|
||||
const [selectedOption, setSelectedOption] = useState<number>(0);
|
||||
const [streamedText, setStreamedText] = useState('');
|
||||
const [streamComplete, setStreamComplete] = useState(false);
|
||||
const [companyAdded, setCompanyAdded] = useState(false);
|
||||
const [personAdded, setPersonAdded] = useState(false);
|
||||
const [tasksAdded, setTasksAdded] = useState(false);
|
||||
const [recordReady, setRecordReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalScene !== undefined) {
|
||||
const clamped = Math.max(
|
||||
0,
|
||||
Math.min(externalScene, PROMPT_OPTIONS.length - 1),
|
||||
);
|
||||
setSelectedOption(clamped);
|
||||
}
|
||||
}, [externalScene]);
|
||||
const { externalScene, playbackEnabled = true } = options;
|
||||
const [streamedLength, setStreamedLength] = useState(0);
|
||||
const [completedStepCount, setCompletedStepCount] = useState(0);
|
||||
const selectedOption =
|
||||
externalScene !== undefined
|
||||
? Math.max(0, Math.min(externalScene, PRODUCT_VISUAL_SCENES.length - 1))
|
||||
: 0;
|
||||
|
||||
const {
|
||||
activeItem,
|
||||
activeLabel,
|
||||
activeItemId,
|
||||
activeItemLabel,
|
||||
activePage,
|
||||
handleSelectLabel,
|
||||
handleToggleFolder,
|
||||
favorites,
|
||||
highlightedItemId,
|
||||
openFolderIds,
|
||||
revealedObjectIds,
|
||||
workspaceNav,
|
||||
} = useAppPreviewState(visual);
|
||||
selectPageItem,
|
||||
toggleFolder,
|
||||
workspaceEntries,
|
||||
} = useAppPreviewExperience(visual);
|
||||
|
||||
let displayPage = activePage;
|
||||
if (selectedOption === 3 && recordReady) {
|
||||
displayPage = QONTO_RECORD_PAGE;
|
||||
} else if (activePage != null && activePage.type === 'table') {
|
||||
const title = activePage.header?.title;
|
||||
if (companyAdded && title === 'All Companies') {
|
||||
displayPage = {
|
||||
...activePage,
|
||||
header: {
|
||||
...activePage.header,
|
||||
count: (activePage.header.count ?? 0) + 1,
|
||||
},
|
||||
rows: [NEW_COMPANY_ROW, ...activePage.rows],
|
||||
};
|
||||
} else if (personAdded && title === 'All People') {
|
||||
displayPage = {
|
||||
...activePage,
|
||||
header: {
|
||||
...activePage.header,
|
||||
count: (activePage.header.count ?? 0) + 1,
|
||||
},
|
||||
rows: [NEW_PERSON_ROW, ...activePage.rows],
|
||||
};
|
||||
} else if (tasksAdded && title === 'All Tasks') {
|
||||
displayPage = {
|
||||
...activePage,
|
||||
header: {
|
||||
...activePage.header,
|
||||
count: (activePage.header.count ?? 0) + NEW_TASK_ROWS.length,
|
||||
},
|
||||
rows: [...NEW_TASK_ROWS, ...activePage.rows],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isScrollDriven = externalScene !== undefined;
|
||||
const selectedScene = PRODUCT_VISUAL_SCENES[selectedOption];
|
||||
const fullText = selectedScene.responseText;
|
||||
const fullTextVisibleLength = getVisibleLength(fullText);
|
||||
const streamComplete = streamedLength >= fullTextVisibleLength;
|
||||
const streamProgress = getStreamProgress(
|
||||
streamedLength,
|
||||
fullTextVisibleLength,
|
||||
);
|
||||
const displayPage = resolveDisplayPage(
|
||||
activePage,
|
||||
activeItemId,
|
||||
selectedScene,
|
||||
streamProgress,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const option = PROMPT_OPTIONS[selectedOption];
|
||||
const fullText = option.response;
|
||||
setStreamedLength(0);
|
||||
setCompletedStepCount(0);
|
||||
selectPageItem(selectedScene.initialPageItemId);
|
||||
|
||||
if (isScrollDriven) {
|
||||
if (selectedOption === 3) {
|
||||
setRecordReady(true);
|
||||
} else {
|
||||
const firstStep = option.navSteps[0];
|
||||
if (firstStep) {
|
||||
handleSelectLabel(firstStep.target);
|
||||
}
|
||||
}
|
||||
if (selectedOption === 0) {
|
||||
setCompanyAdded(true);
|
||||
}
|
||||
if (selectedOption === 2) {
|
||||
setTasksAdded(true);
|
||||
}
|
||||
if (!playbackEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const completedSteps = new Set<number>();
|
||||
let companyInjected = false;
|
||||
let personInjected = false;
|
||||
let tasksInjected = false;
|
||||
let recordShown = false;
|
||||
setStreamedText('');
|
||||
setStreamComplete(false);
|
||||
if (!isScrollDriven) {
|
||||
setCompanyAdded(false);
|
||||
setPersonAdded(false);
|
||||
setTasksAdded(false);
|
||||
setRecordReady(false);
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
index += 1;
|
||||
setStreamedText(fullText.slice(0, index));
|
||||
const progress = index / fullText.length;
|
||||
option.navSteps.forEach((step, stepIndex) => {
|
||||
if (!completedSteps.has(stepIndex) && progress >= step.at) {
|
||||
completedSteps.add(stepIndex);
|
||||
handleSelectLabel(step.target);
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
let streamInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const startStreaming = () => {
|
||||
if (cancelled || fullTextVisibleLength === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
let followUpPageSelected = false;
|
||||
|
||||
streamInterval = setInterval(() => {
|
||||
index = Math.min(index + 1, fullTextVisibleLength);
|
||||
setStreamedLength(index);
|
||||
|
||||
if (
|
||||
!followUpPageSelected &&
|
||||
selectedScene.followUpPageItemId &&
|
||||
getStreamProgress(index, fullTextVisibleLength) >= 0.6
|
||||
) {
|
||||
followUpPageSelected = true;
|
||||
selectPageItem(selectedScene.followUpPageItemId);
|
||||
}
|
||||
});
|
||||
if (selectedOption === 0) {
|
||||
if (!companyInjected && progress >= 0.2) {
|
||||
companyInjected = true;
|
||||
setCompanyAdded(true);
|
||||
}
|
||||
if (!personInjected && progress >= 0.6) {
|
||||
personInjected = true;
|
||||
setPersonAdded(true);
|
||||
|
||||
if (index >= fullTextVisibleLength && streamInterval) {
|
||||
clearInterval(streamInterval);
|
||||
}
|
||||
}, 20);
|
||||
};
|
||||
|
||||
// Agentic preamble: play thinking + tool steps in sequence, then stream the answer.
|
||||
const steps = selectedScene.steps ?? [];
|
||||
|
||||
const playStep = (stepIndex: number) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (selectedOption === 2 && !tasksInjected && progress >= 0.3) {
|
||||
tasksInjected = true;
|
||||
setTasksAdded(true);
|
||||
|
||||
if (stepIndex >= steps.length) {
|
||||
startStreaming();
|
||||
return;
|
||||
}
|
||||
if (selectedOption === 3 && !recordShown && progress >= 0.5) {
|
||||
recordShown = true;
|
||||
setRecordReady(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCompletedStepCount(stepIndex + 1);
|
||||
playStep(stepIndex + 1);
|
||||
}, steps[stepIndex].durationMs);
|
||||
|
||||
timers.push(timer);
|
||||
};
|
||||
|
||||
playStep(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
timers.forEach(clearTimeout);
|
||||
|
||||
if (streamInterval) {
|
||||
clearInterval(streamInterval);
|
||||
}
|
||||
if (index >= fullText.length) {
|
||||
clearInterval(interval);
|
||||
setStreamComplete(true);
|
||||
}
|
||||
}, 20);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedOption, handleSelectLabel, isScrollDriven]);
|
||||
};
|
||||
}, [fullTextVisibleLength, playbackEnabled, selectPageItem, selectedScene]);
|
||||
|
||||
const agentSteps = selectedScene.steps ?? [];
|
||||
const preambleComplete = completedStepCount >= agentSteps.length;
|
||||
const activeStepIndex = preambleComplete ? -1 : completedStepCount;
|
||||
|
||||
return {
|
||||
activeItem,
|
||||
activeLabel,
|
||||
activeItemId,
|
||||
activeItemLabel,
|
||||
activeStepIndex,
|
||||
agentSteps,
|
||||
completedStepCount,
|
||||
displayPage,
|
||||
handleOptionSelect: setSelectedOption,
|
||||
handleSelectLabel,
|
||||
handleToggleFolder,
|
||||
favorites,
|
||||
highlightedItemId,
|
||||
isScrollDriven,
|
||||
openFolderIds,
|
||||
revealedObjectIds,
|
||||
selectedOption,
|
||||
selectPageItem,
|
||||
selectedScene,
|
||||
streamComplete,
|
||||
streamedText,
|
||||
workspaceNav,
|
||||
streamedTextVisibleLength: streamedLength,
|
||||
toggleFolder,
|
||||
workspaceEntries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,7 +246,6 @@ export function MenuDrawer({ navItems, scheme, socialLinks }: MenuDrawerProps) {
|
||||
.filter((item) => item.showInDrawer)
|
||||
.map((item, index) => {
|
||||
const IconComponent = SOCIAL_ICONS[item.icon];
|
||||
if (!IconComponent) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.href}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user