Fix app design 6 (#19827)

Unify application display page and isntalled page

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2026-04-20 09:29:25 +02:00
committed by GitHub
parent 42f57db005
commit 5dd7eba911
30 changed files with 2154 additions and 1602 deletions
@@ -0,0 +1,232 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ComponentType, type ReactNode } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
IconAlertTriangle,
IconBrandNpm,
IconLink,
IconMail,
IconWorld,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type ContentEntry = {
icon: ComponentType<{ size?: number }>;
count: number;
one: string;
many: string;
};
export type DeveloperLinks = {
websiteUrl?: string;
termsUrl?: string;
emailSupport?: string;
issueReportUrl?: string;
sourcePackageUrl?: string;
};
type SettingsApplicationAboutSidebarProps = {
actionButton?: ReactNode;
author?: string;
category?: string;
contentEntries?: ContentEntry[];
currentVersion?: string;
latestAvailableVersion?: string;
developerLinks?: DeveloperLinks;
};
const StyledSidebar = styled.div`
flex-shrink: 0;
width: 140px;
`;
const StyledSidebarSection = styled.div`
padding: ${themeCssVariables.spacing[3]} 0;
&:first-of-type {
padding-top: 0;
}
`;
const StyledSidebarLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledSidebarValue = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledContentItem = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledLink = styled.a`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
&:last-of-type {
margin-bottom: 0;
}
`;
export const SettingsApplicationAboutSidebar = ({
actionButton,
author,
category,
contentEntries,
currentVersion,
latestAvailableVersion,
developerLinks,
}: SettingsApplicationAboutSidebarProps) => {
const isSafeUrl = (url: string | undefined): url is string => {
if (!isNonEmptyString(url)) return false;
try {
const parsed = new URL(url);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
};
const filteredContentEntries = (contentEntries ?? []).filter(
(entry) => entry.count > 0,
);
const hasDeveloperLinks =
isDefined(developerLinks) &&
(isNonEmptyString(developerLinks.websiteUrl) ||
isNonEmptyString(developerLinks.termsUrl) ||
isNonEmptyString(developerLinks.emailSupport) ||
isNonEmptyString(developerLinks.issueReportUrl) ||
isNonEmptyString(developerLinks.sourcePackageUrl));
return (
<StyledSidebar>
{isDefined(actionButton) && (
<StyledSidebarSection>{actionButton}</StyledSidebarSection>
)}
{isDefined(author) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
<StyledSidebarValue>{author}</StyledSidebarValue>
</StyledSidebarSection>
)}
{isDefined(category) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
<StyledSidebarValue>{category}</StyledSidebarValue>
</StyledSidebarSection>
)}
{filteredContentEntries.length > 0 && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
{filteredContentEntries.map((entry) => (
<StyledContentItem key={entry.one}>
<entry.icon size={16} />
{entry.count} {entry.count === 1 ? entry.one : entry.many}
</StyledContentItem>
))}
</StyledSidebarSection>
)}
{isDefined(currentVersion) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Current`}</StyledSidebarLabel>
<StyledSidebarValue>{currentVersion}</StyledSidebarValue>
</StyledSidebarSection>
)}
{isDefined(latestAvailableVersion) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
<StyledSidebarValue>{latestAvailableVersion}</StyledSidebarValue>
</StyledSidebarSection>
)}
{hasDeveloperLinks && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
{isSafeUrl(developerLinks.websiteUrl) && (
<StyledLink
href={developerLinks.websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
)}
{isSafeUrl(developerLinks.termsUrl) && (
<StyledLink
href={developerLinks.termsUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconLink size={16} />
{t`Terms / Privacy`}
</StyledLink>
)}
{isNonEmptyString(developerLinks.emailSupport) && (
<StyledLink
href={`mailto:${developerLinks.emailSupport}`}
target="_blank"
rel="noopener noreferrer"
>
<IconMail size={16} />
{t`Email support`}
</StyledLink>
)}
{isSafeUrl(developerLinks.issueReportUrl) && (
<StyledLink
href={developerLinks.issueReportUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconAlertTriangle size={16} />
{t`Report an issue`}
</StyledLink>
)}
{isSafeUrl(developerLinks.sourcePackageUrl) && (
<StyledLink
href={developerLinks.sourcePackageUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconBrandNpm size={16} />
{t`Npm package`}
</StyledLink>
)}
</StyledSidebarSection>
)}
</StyledSidebar>
);
};
@@ -0,0 +1,97 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationScreenshotGalleryProps = {
screenshots: string[];
displayName: string;
};
const StyledScreenshotsContainer = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: 300px;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledScreenshotImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledScreenshotThumbnails = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const StyledThumbnail = styled.div<{ isSelected?: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid
${({ isSelected }) =>
isSelected
? themeCssVariables.color.blue
: themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
flex: 1;
height: 60px;
justify-content: center;
overflow: hidden;
&:hover {
border-color: ${themeCssVariables.color.blue};
}
`;
const StyledThumbnailImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
export const SettingsApplicationScreenshotGallery = ({
screenshots,
displayName,
}: SettingsApplicationScreenshotGalleryProps) => {
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
if (screenshots.length === 0) {
return null;
}
const safeIndex = Math.min(selectedScreenshotIndex, screenshots.length - 1);
return (
<>
<StyledScreenshotsContainer>
<StyledScreenshotImage
src={screenshots[safeIndex]}
alt={`${displayName} screenshot ${safeIndex + 1}`}
/>
</StyledScreenshotsContainer>
<StyledScreenshotThumbnails>
{screenshots.slice(0, 6).map((screenshot, index) => (
<StyledThumbnail
key={index}
isSelected={index === selectedScreenshotIndex}
onClick={() => setSelectedScreenshotIndex(index)}
>
<StyledThumbnailImage
src={screenshot}
alt={`${displayName} thumbnail ${index + 1}`}
/>
</StyledThumbnail>
))}
</StyledScreenshotThumbnails>
</>
);
};