mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-25 08:59:13 +00:00
refactor(info): improve UI consistency and polish
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
$content-width: min(100%, 1100px);
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
box-sizing: border-box; /* reset */
|
box-sizing: border-box; /* reset */
|
||||||
@@ -16,56 +18,100 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* =================== HEADER ===================*/
|
||||||
|
|
||||||
|
.project-header {
|
||||||
|
width: $content-width;
|
||||||
|
margin-inline: auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(12px, 2vw, 24px);
|
||||||
|
|
||||||
|
padding-bottom: $view-element-gap;
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
max-width: min(200px, 30vw);
|
max-width: min(200px, 30vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: $header-font-size;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: $base-font-size;
|
||||||
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================== CONTENT ===================*/
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-height: 100%;
|
width: $content-width;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: min(calc(100vw - 4rem), 960px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
|
||||||
gap: $view-element-gap;
|
gap: $view-element-gap;
|
||||||
|
|
||||||
|
padding-block: $view-element-gap;
|
||||||
padding-bottom: 10vh;
|
padding-bottom: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info__card {
|
||||||
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
|
border-radius: $element-border-radius;
|
||||||
|
padding: $view-block-padding $view-inline-padding;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__media {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $view-element-gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__media .info__value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.info__label {
|
.info__label {
|
||||||
|
font-size: $timer-label-size;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__value {
|
.info__value {
|
||||||
white-space: break-spaces;
|
white-space: break-spaces;
|
||||||
}
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
.info__custom {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__image-container {
|
.info__image-container {
|
||||||
display: flex;
|
flex: 0 0 min(128px, 25%);
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 192px;
|
|
||||||
height: 192px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__image {
|
.info__image {
|
||||||
max-width: 100%;
|
display: block;
|
||||||
max-height: 100%;
|
width: 100%;
|
||||||
object-fit: contain;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link.info__value {
|
.link.info__value {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: $view-element-gap;
|
gap: 0.35em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
|
|
||||||
@@ -78,12 +124,13 @@
|
|||||||
/* =================== MOBILE ===================*/
|
/* =================== MOBILE ===================*/
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.project {
|
.project {
|
||||||
|
.project-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
.logo img {
|
.logo img {
|
||||||
height: min(50px, 10vh);
|
height: min(50px, 10vh);
|
||||||
}
|
}
|
||||||
.info__image-container {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
|
import { type ReactNode, useState } from 'react';
|
||||||
import { IoOpenOutline } from 'react-icons/io5';
|
import { IoOpenOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
@@ -41,56 +42,50 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
||||||
<EmptyPage text={getLocalizedString('common.no_data')} />;
|
<EmptyPage text={getLocalizedString('common.no_data')} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
||||||
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
{hasHeader && (
|
||||||
|
<div className='project-header'>
|
||||||
|
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||||
|
<div className='project-header__text'>
|
||||||
|
{projectData.title && <div className='title'>{projectData.title}</div>}
|
||||||
|
{projectData.description && <div className='description'>{projectData.description}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className='info'>
|
<div className='info'>
|
||||||
{projectData.title && (
|
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
|
||||||
<div>
|
|
||||||
<div className='info__label'>{getLocalizedString('project.title')}</div>
|
|
||||||
<div className='info__value'>{projectData.title}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{projectData.description && (
|
|
||||||
<div>
|
|
||||||
<div className='info__label'>{getLocalizedString('project.description')}</div>
|
|
||||||
<div className='info__value'>{projectData.description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{projectData.info && (
|
|
||||||
<div>
|
|
||||||
<div className='info__label'>{getLocalizedString('project.info')}</div>
|
|
||||||
<div className='info__value'>{projectData.info}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{projectData.url && (
|
{projectData.url && (
|
||||||
<div>
|
<div className='info__card'>
|
||||||
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
||||||
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
||||||
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
|
{projectData.url}
|
||||||
|
<IoOpenOutline style={{ fontSize: '1em' }} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{projectData.custom.map((info, idx) => {
|
{projectData.custom.map((info, idx) => {
|
||||||
const hasUrl = Boolean(info.url);
|
const hasImage = Boolean(info.url);
|
||||||
return (
|
return (
|
||||||
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
|
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
|
||||||
<div key={`${info.title}-${idx}`} className='info__custom'>
|
<div key={`${info.title}-${idx}`} className='info__card'>
|
||||||
{hasUrl && (
|
{info.title && <div className='info__label'>{info.title}</div>}
|
||||||
<div className='info__image-container'>
|
{hasImage ? (
|
||||||
<img className='info__image' src={info.url} loading='lazy' />
|
<div className='info__media'>
|
||||||
|
<InfoImage src={info.url} />
|
||||||
|
{info.value && <div className='info__value'>{info.value}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
info.value && <div className='info__value'>{info.value}</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
|
||||||
<div className='info__label'>{info.title}</div>
|
|
||||||
<div className='info__value'>{info.value}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -98,3 +93,35 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InfoCardProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ label, children }: InfoCardProps) {
|
||||||
|
return (
|
||||||
|
<div className='info__card'>
|
||||||
|
<div className='info__label'>{label}</div>
|
||||||
|
<div className='info__value'>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an user provided image, collapsing itself if the image fails to load
|
||||||
|
* We remove the container along with the image to avoid leaving an empty gap in the card
|
||||||
|
*/
|
||||||
|
function InfoImage({ src }: { src: string }) {
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='info__image-container'>
|
||||||
|
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user