tududi/frontend/components/Project/ProjectBanner.tsx
Chris e80847b01f
Fix project name overflow and add 6-word validation limit (#972)
* Fix project name overflow and add validation

This commit addresses issue #971 by implementing both UI fixes and
validation to prevent excessively long project names.

Changes:
1. Add word-break and line-clamp to ProjectBanner.tsx to handle
   overflow gracefully with line-clamp-3 for names
2. Add frontend validation in ProjectModal.tsx limiting names to
   6 words maximum
3. Add backend validation in project.js model with custom wordCount
   validator
4. Show user-friendly error messages when validation fails

This ensures project names remain concise and UI-friendly while
preventing the extreme overflow cases that were possible before.

Fixes #971

* Add overflow-hidden to make line-clamp work properly

The line-clamp utility requires explicit overflow-hidden to function
correctly. Without it, the text continues to display in full rather
than being truncated with ellipsis.

* Fix line-clamp using inline CSS styles

Tailwind's line-clamp utilities weren't working, so switched to using
inline styles with the standard CSS approach:
- display: -webkit-box
- -webkit-line-clamp: 3
- -webkit-box-orient: vertical

This ensures the text truncation works reliably across browsers.

* Use Tailwind line-clamp utilities (already defined in CSS)

The project already has line-clamp-1/2/3 utilities properly defined
in tailwind.css with all the necessary webkit properties. Simplified
the component to use these existing utilities instead of inline styles.

* Add dedicated CSS classes with !important for line-clamp

Created custom project-name-clamp and project-desc-clamp classes
with !important flags to ensure they override any conflicting styles.
This should finally fix the text truncation issue.

* Use component-scoped styles for line-clamp

Adding inline style tag in the component to ensure the line-clamp
CSS is definitely loaded and applied. This bypasses any potential
issues with external CSS compilation or loading order.

* Change project name line-clamp from 3 to 2 lines

Limiting project name display to 2 lines with ellipsis for better
visual density and cleaner appearance.

* Increase line-height for project name in banner

Added line-height: 1.3 to project name for better readability
and visual spacing between lines.
2026-03-24 17:36:24 +02:00

226 lines
10 KiB
TypeScript

import React, { RefObject } from 'react';
import {
TagIcon,
Squares2X2Icon,
PencilSquareIcon,
TrashIcon,
ShareIcon,
CameraIcon,
} from '@heroicons/react/24/outline';
import BannerBadge from '../Shared/BannerBadge';
import { Project } from '../../entities/Project';
import { Area } from '../../entities/Area';
import { useNavigate } from 'react-router-dom';
import { TFunction } from 'i18next';
import {
getCreatorFromBannerUrl,
isPresetBanner,
} from '../../utils/bannersService';
import { getAssetPath } from '../../config/paths';
interface ProjectBannerProps {
project: Project;
areas: Area[];
t: TFunction;
getStatusIcon: (status: string) => React.ReactNode;
onDeleteClick: () => void;
editButtonRef: RefObject<HTMLButtonElement>;
onEditBannerClick?: () => void;
}
const ProjectBanner: React.FC<ProjectBannerProps> = ({
project,
areas,
t,
getStatusIcon,
onDeleteClick,
editButtonRef,
onEditBannerClick,
}) => {
const navigate = useNavigate();
const creatorName =
project.image_url && isPresetBanner(project.image_url)
? getCreatorFromBannerUrl(project.image_url)
: null;
return (
<div className="w-full">
<style>{`
.project-banner-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.project-banner-desc {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
`}</style>
<div className="mb-6 overflow-hidden relative group">
{project.image_url ? (
<img
src={getAssetPath(project.image_url)}
alt={project.name}
className="w-full h-[282px] object-cover"
/>
) : (
<div className="w-full h-[282px] bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
{creatorName && (
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded backdrop-blur-sm">
Photo by {creatorName}
</div>
)}
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
<div className="text-center px-4 w-full max-w-5xl">
<h1 className="text-4xl md:text-5xl font-bold text-white drop-shadow-lg project-banner-name">
{project.name}
</h1>
{project.description && (
<p className="text-lg md:text-xl text-white/90 mt-2 font-light drop-shadow-md max-w-2xl mx-auto project-banner-desc">
{project.description}
</p>
)}
</div>
</div>
<div className="absolute bottom-2 left-2 right-14 flex items-center flex-wrap gap-2">
{project.status && (
<BannerBadge>
{getStatusIcon(project.status)}
<span className="text-xs text-white/90 font-medium">
{t(`projectStatus.${project.status}`)}
</span>
</BannerBadge>
)}
{project.tags && project.tags.length > 0 && (
<BannerBadge>
<TagIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
<span className="text-xs text-white/90 font-medium">
{project.tags.map((tag, index) => (
<React.Fragment
key={tag.uid || tag.id || index}
>
<button
onClick={() => {
if (tag.uid) {
const slug = tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(/^-|-$/g, '');
navigate(
`/tag/${tag.uid}-${slug}`
);
} else {
navigate(
`/tag/${encodeURIComponent(tag.name)}`
);
}
}}
className="hover:text-blue-200 transition-colors cursor-pointer"
>
{tag.name}
</button>
{index <
(project.tags?.length || 0) - 1 && (
<span className="text-white/60">
,{' '}
</span>
)}
</React.Fragment>
))}
</span>
</BannerBadge>
)}
{(project.area || (project as any).Area) && (
<BannerBadge>
<Squares2X2Icon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
<button
onClick={() => {
const projectArea =
project.area || (project as any).Area;
const area = areas.find(
(a) => a.id === projectArea.id
);
const areaUid = area?.uid;
if (!areaUid) return;
const areaSlug = projectArea.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
navigate(
`/projects?area=${areaUid}-${areaSlug}`
);
}}
className="text-xs text-white/90 hover:text-blue-200 transition-colors cursor-pointer font-medium"
>
{(project.area || (project as any).Area)?.name}
</button>
</BannerBadge>
)}
{project.is_shared && (
<BannerBadge>
<ShareIcon className="h-3 w-3 text-green-500 dark:text-green-400 flex-shrink-0 mt-0.5" />
<span className="text-xs text-white/90 font-medium">
{t('projects.shared', 'Shared')}
</span>
</BannerBadge>
)}
</div>
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{onEditBannerClick && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEditBannerClick();
}}
className="p-2 bg-black bg-opacity-50 text-purple-400 hover:text-purple-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
title={t('project.projectImage', 'Project image')}
>
<CameraIcon className="h-5 w-5" />
</button>
)}
<button
ref={editButtonRef}
type="button"
className="p-2 bg-black bg-opacity-50 text-blue-400 hover:text-blue-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
title={t('project.editProject', 'Edit project')}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteClick();
}}
className="p-2 bg-black bg-opacity-50 text-red-400 hover:text-red-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
title={t('project.deleteProject', 'Delete project')}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default ProjectBanner;