Browse Source

refactor: address code review feedback

- Extract LogoItem interface to separate file
- Move all styles into :host block
- Add Bootstrap utility classes to HTML template
- Order object properties alphabetically
- Clean up landing-page.scss duplicate styles
- Remove LOGO_CAROUSEL_IMPLEMENTATION.md file
pull/5671/head
URAYUSHJAIN 1 month ago
parent
commit
e8923e4073
  1. 98
      LOGO_CAROUSEL_IMPLEMENTATION.md
  2. 2
      apps/client/src/app/pages/landing/landing-page.html
  3. 76
      apps/client/src/app/pages/landing/landing-page.scss
  4. 7
      libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts
  5. 6
      libs/ui/src/lib/logo-carousel/logo-carousel.component.html
  6. 339
      libs/ui/src/lib/logo-carousel/logo-carousel.component.scss
  7. 2
      libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts
  8. 82
      libs/ui/src/lib/logo-carousel/logo-carousel.component.ts

98
LOGO_CAROUSEL_IMPLEMENTATION.md

@ -1,98 +0,0 @@
# Logo Carousel Component Implementation
## Summary
Successfully implemented the infinite logo carousel component as requested in issue #5660.
## Files Created
### 1. Component Files
- `libs/ui/src/lib/logo-carousel/logo-carousel.component.ts` - Main component logic
- `libs/ui/src/lib/logo-carousel/logo-carousel.component.html` - Template
- `libs/ui/src/lib/logo-carousel/logo-carousel.component.scss` - Styles with CSS animation
- `libs/ui/src/lib/logo-carousel/index.ts` - Export file
- `libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts` - Storybook story
### 2. Modified Files
- `apps/client/src/app/pages/landing/landing-page.component.ts` - Added import and component usage
- `apps/client/src/app/pages/landing/landing-page.html` - Replaced static logo grid with carousel
## Implementation Details
### Features Implemented
**Pure CSS Animation**: Uses `@keyframes` for infinite horizontal scrolling
**Responsive Design**: Adapts to different screen sizes (mobile, tablet, desktop)
**Accessibility**:
- Proper ARIA labels
- Pause animation on hover
- Screen reader friendly
**Dark Theme Support**: Automatic theme switching
**Performance**:
- CSS-only animation (no JavaScript)
- Uses `transform: translateX()` for GPU acceleration
**Seamless Loop**: Duplicates logos for continuous scrolling
**Interactive**: Hover effects with scaling and opacity changes
### Technical Approach
- **Animation Technique**: Duplicates the logo set and uses CSS `translateX(-50%)` to create seamless infinite scroll
- **Browser Compatibility**: Added `-webkit-` prefixes for mask properties
- **Styling**: Copied and adapted existing logo styles from landing page
- **Theming**: Uses CSS custom properties for theme-aware gradients
### Animation Details
- **Duration**: 40 seconds for full cycle
- **Direction**: Left to right horizontal scroll
- **Easing**: Linear for consistent speed
- **Interaction**: Pauses on hover for accessibility
### CSS Features
- Gradient fade masks on left/right edges for smooth visual effect
- Responsive logo sizing (3rem height on desktop, scales down on mobile)
- Hover effects with 1.05x scale and opacity fade
- Proper mask image handling for SVG logos
## Integration
The component is integrated into the landing page replacing the previous static 4-column logo grid. The "As seen in" text remains above the carousel.
## Browser Support
- Modern browsers with CSS mask support
- Fallback styles for older browsers
- Responsive design for all device sizes
## Usage
```html
<gf-logo-carousel></gf-logo-carousel>
```
The component is self-contained and requires no additional configuration.
## Testing Recommendations
1. Test on different screen sizes
2. Verify smooth animation performance
3. Check accessibility with screen readers
4. Test dark/light theme switching
5. Verify all logo links work correctly
## Next Steps
1. Install dependencies: `npm install`
2. Build application: `npm run build:production`
3. Test in browser: `npm run start:client`
4. View in Storybook: `npm run start:storybook`
The implementation follows the exact requirements from issue #5660:
- ✅ Standalone Angular component at `@ghostfolio/ui/logo-carousel`
- ✅ Pure CSS infinite scrolling animation
- ✅ Integrated into landing page replacing static logos

2
apps/client/src/app/pages/landing/landing-page.html

@ -115,7 +115,7 @@
<small i18n>As seen in</small>
</div>
<div class="col-12">
<gf-logo-carousel></gf-logo-carousel>
<gf-logo-carousel />
</div>
</div>

76
apps/client/src/app/pages/landing/landing-page.scss

@ -34,69 +34,6 @@
&.logo-agplv3 {
mask-image: url('/assets/images/logo-AGPLv3.svg');
}
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
}
.outro-inner-container {
@ -128,18 +65,7 @@
}
.logo {
&.logo-agplv3,
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-openalternative,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
&.logo-agplv3 {
background-color: rgba(var(--light-primary-text));
}
}

7
libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface LogoItem {
className: string;
isMask?: boolean;
name: string;
title: string;
url: string;
}

6
libs/ui/src/lib/logo-carousel/logo-carousel.component.html

@ -1,7 +1,9 @@
<div class="logo-carousel-container">
<div class="logo-carousel-track">
<div class="d-flex logo-carousel-track">
@for (logo of duplicatedLogos; track $index) {
<div class="logo-carousel-item">
<div
class="logo-carousel-item d-flex align-items-center justify-content-center"
>
<a
class="d-block logo"
target="_blank"

339
libs/ui/src/lib/logo-carousel/logo-carousel.component.scss

@ -3,171 +3,192 @@
width: 100%;
overflow: hidden;
position: relative;
}
.logo-carousel-container {
width: 100%;
overflow: hidden;
position: relative;
.logo-carousel-container {
width: 100%;
overflow: hidden;
position: relative;
// Fade gradient mask for smooth edges
&::before,
&::after {
content: '';
position: absolute;
top: 0;
width: 100px;
height: 100%;
z-index: 2;
pointer-events: none;
}
// Fade gradient mask for smooth edges
&::before,
&::after {
content: '';
position: absolute;
top: 0;
width: 100px;
height: 100%;
z-index: 2;
pointer-events: none;
}
&::before {
left: 0;
background: linear-gradient(
to right,
rgba(var(--palette-background-background), 1) 0%,
rgba(var(--palette-background-background), 0) 100%
);
}
&::before {
left: 0;
background: linear-gradient(
to right,
rgba(var(--palette-background-background), 1) 0%,
rgba(var(--palette-background-background), 0) 100%
);
}
&::after {
right: 0;
background: linear-gradient(
to left,
rgba(var(--palette-background-background), 1) 0%,
rgba(var(--palette-background-background), 0) 100%
);
&::after {
right: 0;
background: linear-gradient(
to left,
rgba(var(--palette-background-background), 1) 0%,
rgba(var(--palette-background-background), 0) 100%
);
}
// Responsive adjustments for mobile
@media (max-width: 768px) {
&::before,
&::after {
width: 50px;
}
}
@media (max-width: 576px) {
&::before,
&::after {
width: 30px;
}
}
}
}
.logo-carousel-track {
display: flex;
align-items: center;
animation: scroll 40s linear infinite;
width: fit-content;
.logo-carousel-track {
align-items: center;
animation: scroll 40s linear infinite;
width: fit-content;
// Pause animation on hover for accessibility
&:hover {
animation-play-state: paused;
// Pause animation on hover for accessibility
&:hover {
animation-play-state: paused;
}
}
}
.logo-carousel-item {
flex-shrink: 0;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 200px;
}
.logo-carousel-item {
flex-shrink: 0;
padding: 1rem 2rem;
min-width: 200px;
.logo {
height: 3rem;
width: 7.5rem;
transition:
opacity 0.3s ease,
transform 0.3s ease;
@media (max-width: 768px) {
min-width: 150px;
padding: 0.75rem 1.5rem;
}
&:hover {
opacity: 0.8;
transform: scale(1.05);
@media (max-width: 576px) {
min-width: 120px;
padding: 0.5rem 1rem;
}
}
&.mask {
background-color: rgba(var(--dark-secondary-text));
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.logo {
height: 3rem;
width: 7.5rem;
transition:
opacity 0.3s ease,
transform 0.3s ease;
&:hover {
opacity: 0.8;
transform: scale(1.05);
}
// Logo-specific styles (copied from landing-page.scss)
&.logo-alternative-to {
-webkit-mask-image: url('/assets/images/logo-alternative-to.svg');
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.mask {
background-color: rgba(var(--dark-secondary-text));
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
// Logo-specific styles (copied from landing-page.scss)
&.logo-alternative-to {
-webkit-mask-image: url('/assets/images/logo-alternative-to.svg');
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-dev-community {
-webkit-mask-image: url('/assets/images/logo-dev-community.svg');
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-hacker-news {
-webkit-mask-image: url('/assets/images/logo-hacker-news.svg');
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-dev-community {
-webkit-mask-image: url('/assets/images/logo-dev-community.svg');
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-openalternative {
-webkit-mask-image: url('/assets/images/logo-openalternative.svg');
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-hacker-news {
-webkit-mask-image: url('/assets/images/logo-hacker-news.svg');
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-privacy-tools {
-webkit-mask-image: url('/assets/images/logo-privacy-tools.svg');
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-openalternative {
-webkit-mask-image: url('/assets/images/logo-openalternative.svg');
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-privacy-tools {
-webkit-mask-image: url('/assets/images/logo-privacy-tools.svg');
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-reddit {
-webkit-mask-image: url('/assets/images/logo-reddit.svg');
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-sackgeld {
-webkit-mask-image: url('/assets/images/logo-sackgeld.png');
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-reddit {
-webkit-mask-image: url('/assets/images/logo-reddit.svg');
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-selfh-st {
-webkit-mask-image: url('/assets/images/logo-selfh-st.svg');
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sackgeld {
-webkit-mask-image: url('/assets/images/logo-sackgeld.png');
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-sourceforge {
-webkit-mask-image: url('/assets/images/logo-sourceforge.svg');
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-selfh-st {
-webkit-mask-image: url('/assets/images/logo-selfh-st.svg');
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-umbrel {
-webkit-mask-image: url('/assets/images/logo-umbrel.svg');
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-sourceforge {
-webkit-mask-image: url('/assets/images/logo-sourceforge.svg');
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-unraid {
-webkit-mask-image: url('/assets/images/logo-unraid.svg');
mask-image: url('/assets/images/logo-unraid.svg');
}
}
&.logo-umbrel {
-webkit-mask-image: url('/assets/images/logo-umbrel.svg');
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
// Animation keyframes for infinite horizontal scroll
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
// Move by exactly half the width (since we duplicated the logos)
transform: translateX(-50%);
&.logo-unraid {
-webkit-mask-image: url('/assets/images/logo-unraid.svg');
mask-image: url('/assets/images/logo-unraid.svg');
}
// Responsive logo sizes
@media (max-width: 768px) {
height: 2.5rem;
width: 6rem;
}
@media (max-width: 576px) {
height: 2rem;
width: 5rem;
}
}
}
@ -208,41 +229,13 @@
}
}
// Responsive adjustments
@media (max-width: 768px) {
.logo-carousel-item {
min-width: 150px;
padding: 0.75rem 1.5rem;
}
.logo {
height: 2.5rem;
width: 6rem;
}
.logo-carousel-container {
&::before,
&::after {
width: 50px;
}
}
}
@media (max-width: 576px) {
.logo-carousel-item {
min-width: 120px;
padding: 0.5rem 1rem;
}
.logo {
height: 2rem;
width: 5rem;
// Animation keyframes for infinite horizontal scroll
@keyframes scroll {
0% {
transform: translateX(0);
}
.logo-carousel-container {
&::before,
&::after {
width: 30px;
}
100% {
// Move by exactly half the width (since we duplicated the logos)
transform: translateX(-50%);
}
}

2
libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts

@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { GfLogoCarouselComponent } from './logo-carousel.component';
const meta: Meta<GfLogoCarouselComponent> = {
title: 'Components/Logo Carousel',
title: 'Logo Carousel',
component: GfLogoCarouselComponent
};

82
libs/ui/src/lib/logo-carousel/logo-carousel.component.ts

@ -1,13 +1,7 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
export interface LogoItem {
name: string;
url: string;
title: string;
className: string;
isMask?: boolean;
}
import { LogoItem } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -19,96 +13,96 @@ export interface LogoItem {
export class GfLogoCarouselComponent {
public readonly logos: LogoItem[] = [
{
className: 'logo-alternative-to',
isMask: true,
name: 'AlternativeTo',
url: 'https://alternativeto.net',
title: 'AlternativeTo - Crowdsourced software recommendations',
className: 'logo-alternative-to',
isMask: true
url: 'https://alternativeto.net'
},
{
className: 'logo-awesome',
name: 'Awesome Selfhosted',
url: 'https://github.com/awesome-selfhosted/awesome-selfhosted',
title:
'Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers',
className: 'logo-awesome'
url: 'https://github.com/awesome-selfhosted/awesome-selfhosted'
},
{
className: 'logo-dev-community',
isMask: true,
name: 'DEV Community',
url: 'https://dev.to',
title:
'DEV Community - A constructive and inclusive social network for software developers',
className: 'logo-dev-community',
isMask: true
url: 'https://dev.to'
},
{
className: 'logo-hacker-news',
isMask: true,
name: 'Hacker News',
url: 'https://news.ycombinator.com',
title: 'Hacker News',
className: 'logo-hacker-news',
isMask: true
url: 'https://news.ycombinator.com'
},
{
className: 'logo-openalternative',
isMask: true,
name: 'OpenAlternative',
url: 'https://openalternative.co',
title: 'OpenAlternative: Open Source Alternatives to Popular Software',
className: 'logo-openalternative',
isMask: true
url: 'https://openalternative.co'
},
{
className: 'logo-privacy-tools',
isMask: true,
name: 'Privacy Tools',
url: 'https://www.privacytools.io',
title: 'Privacy Tools: Software Alternatives and Encryption',
className: 'logo-privacy-tools',
isMask: true
url: 'https://www.privacytools.io'
},
{
className: 'logo-product-hunt',
name: 'Product Hunt',
url: 'https://www.producthunt.com',
title: 'Product Hunt – The best new products in tech.',
className: 'logo-product-hunt'
url: 'https://www.producthunt.com'
},
{
className: 'logo-reddit',
isMask: true,
name: 'Reddit',
url: 'https://www.reddit.com',
title: 'Reddit - Dive into anything',
className: 'logo-reddit',
isMask: true
url: 'https://www.reddit.com'
},
{
className: 'logo-sackgeld',
isMask: true,
name: 'Sackgeld',
url: 'https://www.sackgeld.com',
title: 'Sackgeld.com – Apps für ein höheres Sackgeld',
className: 'logo-sackgeld',
isMask: true
url: 'https://www.sackgeld.com'
},
{
className: 'logo-selfh-st',
isMask: true,
name: 'selfh.st',
url: 'https://selfh.st',
title: 'selfh.st — Self-hosted content and software',
className: 'logo-selfh-st',
isMask: true
url: 'https://selfh.st'
},
{
className: 'logo-sourceforge',
isMask: true,
name: 'SourceForge',
url: 'https://sourceforge.net',
title:
'SourceForge: The Complete Open-Source and Business Software Platform',
className: 'logo-sourceforge',
isMask: true
url: 'https://sourceforge.net'
},
{
className: 'logo-umbrel',
isMask: true,
name: 'Umbrel',
url: 'https://umbrel.com',
title: 'Umbrel — A personal server OS for self-hosting',
className: 'logo-umbrel',
isMask: true
url: 'https://umbrel.com'
},
{
className: 'logo-unraid',
isMask: true,
name: 'Unraid',
url: 'https://unraid.net',
title: 'Unraid | Unleash Your Hardware',
className: 'logo-unraid',
isMask: true
url: 'https://unraid.net'
}
];

Loading…
Cancel
Save