Added project-list and project card components, still WIP

This commit is contained in:
AdamBtech
2025-05-26 15:23:22 +02:00
parent 67dc4736c3
commit 9ecb429131
10 changed files with 1328 additions and 22 deletions

View File

@@ -22,7 +22,7 @@ export const routes: Routes = [
loadComponent: () =>
import(
'./features/project-display/components/projects-list/projects-list.component'
).then((c) => c.ProjectsListComponent),
).then((c) => c.ProjectListComponent),
},
{
path: 'experience',

View File

@@ -0,0 +1,706 @@
/* Base styles with fade-in animations */
.project-card {
min-height: 300px;
cursor: pointer;
/* Animation - initially hidden */
opacity: 0;
transform: scale(0.95) translateY(20px);
animation: cardMaterialize 1.2s ease-out forwards;
}
/* Main card materialization animation */
@keyframes cardMaterialize {
0% {
opacity: 0;
transform: scale(0.95) translateY(20px);
filter: blur(2px);
}
20% {
opacity: 0.3;
transform: scale(0.98) translateY(10px);
filter: blur(1px);
}
60% {
opacity: 0.8;
transform: scale(1.01) translateY(-2px);
filter: blur(0px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0px);
}
}
/* Scanning line effect during load */
.project-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 2px;
background: linear-gradient(90deg,
transparent,
var(--color-nier-accent),
transparent
);
animation: scanLine 2s ease-out 0.2s forwards;
z-index: 20;
}
@keyframes scanLine {
0% {
left: -100%;
opacity: 0;
}
50% {
opacity: 1;
}
100% {
left: 100%;
opacity: 0;
}
}
.project-card:not(.redacted):hover .click-indicator {
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Header section animation */
.project-header {
opacity: 0;
transform: translateX(-30px);
animation: headerSlideIn 0.8s ease-out 0.3s forwards;
}
@keyframes headerSlideIn {
0% {
opacity: 0;
transform: translateX(-30px);
border-bottom-color: transparent;
}
50% {
opacity: 0.6;
transform: translateX(-5px);
}
100% {
opacity: 1;
transform: translateX(0);
border-bottom-color: var(--color-nier-border);
}
}
/* Status text fade-in effect (reduced glitch) */
.project-status {
opacity: 0;
animation: statusFadeIn 0.6s ease-out 0.5s forwards;
}
@keyframes statusFadeIn {
0% {
opacity: 0;
transform: translateX(-10px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
/* Title fade and scale */
.project-title {
opacity: 0;
transform: scale(0.9);
animation: titleExpand 0.7s ease-out 0.7s forwards;
}
@keyframes titleExpand {
0% {
opacity: 0;
transform: scale(0.9);
letter-spacing: -0.1em;
}
70% {
opacity: 0.8;
transform: scale(1.02);
letter-spacing: 0.05em;
}
100% {
opacity: 1;
transform: scale(1);
letter-spacing: 0.1em;
}
}
/* Body content staggered animation */
.project-body {
opacity: 0;
animation: bodyFadeUp 0.8s ease-out 0.9s forwards;
}
@keyframes bodyFadeUp {
0% {
opacity: 0;
transform: translateY(15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.project-detail {
display: flex;
align-items: flex-start;
font-size: 0.875rem;
line-height: 1.5;
/* Animation */
opacity: 0;
transform: translateX(-20px);
animation: detailSlideIn 0.5s ease-out forwards;
}
.project-detail:nth-child(1) { animation-delay: 1.1s; }
.project-detail:nth-child(2) { animation-delay: 1.2s; }
.project-detail:nth-child(3) { animation-delay: 1.3s; }
@keyframes detailSlideIn {
0% {
opacity: 0;
transform: translateX(-20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.detail-label {
font-weight: 500;
color: var(--color-nier-accent);
min-width: 120px;
margin-right: 1rem;
flex-shrink: 0;
}
.detail-value {
flex: 1;
word-break: break-word;
}
.tech-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tech-item {
background-color: var(--color-nier-mid);
color: var(--color-nier-text-dark);
border: 1px solid var(--color-nier-border);
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.05em;
border-radius: 2px;
/* Animation */
opacity: 0;
transform: scale(0.8);
animation: techItemPop 0.3s ease-out forwards;
animation-delay: 1.6s; /* Default delay, overridden by Angular */
}
@keyframes techItemPop {
0% {
opacity: 0;
transform: scale(0.8);
border-color: transparent;
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
border-color: var(--color-nier-border);
}
}
/* Tech stack animation */
.tech-stack {
opacity: 0;
animation: techStackReveal 0.6s ease-out 1.4s forwards;
}
@keyframes techStackReveal {
0% {
opacity: 0;
transform: translateY(10px);
border-top-color: transparent;
}
100% {
opacity: 1;
transform: translateY(0);
border-top-color: var(--color-nier-border);
}
}
.access-section {
padding: 1rem 1.5rem;
background-color: var(--color-nier-checkered-bg);
background-size: 0.2rem 0.2rem;
background-image:
linear-gradient(to right, var(--color-nier-checkered-grid) 1px, rgba(0, 0, 0, 0) 1px),
linear-gradient(to bottom, var(--color-nier-checkered-grid) 1px, rgba(0, 0, 0, 0) 1px);
border-top: 1px solid var(--color-nier-border);
display: flex;
gap: 1rem;
/* Animation */
opacity: 0;
transform: translateY(20px);
animation: accessSectionRise 0.7s ease-out 1.8s forwards;
}
@keyframes accessSectionRise {
0% {
opacity: 0;
transform: translateY(20px);
border-top-color: transparent;
}
50% {
opacity: 0.7;
transform: translateY(-2px);
}
100% {
opacity: 1;
transform: translateY(0);
border-top-color: var(--color-nier-border);
}
}
.access-btn {
background-color: transparent;
border: 2px solid var(--color-nier-accent);
color: var(--color-nier-accent);
padding: 0.5rem 1rem;
font-family: var(--font-terminal);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: 0.05em;
flex: 1;
/* Animation */
opacity: 0;
transform: scale(0.9);
animation: buttonMaterialize 0.4s ease-out forwards;
}
.access-btn:first-child { animation-delay: 2.0s; }
.access-btn:last-child { animation-delay: 2.1s; }
@keyframes buttonMaterialize {
0% {
opacity: 0;
transform: scale(0.9);
border-color: transparent;
box-shadow: none;
}
30% {
opacity: 0.6;
transform: scale(1.05);
border-color: var(--color-nier-accent);
box-shadow: 0 0 10px rgba(255, 201, 102, 0.3);
}
100% {
opacity: 1;
transform: scale(1);
border-color: var(--color-nier-accent);
box-shadow: none;
}
}
.access-btn:hover {
background-color: var(--color-nier-accent);
color: var(--color-nier-text-light);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.click-indicator {
position: absolute;
bottom: 0.5rem;
right: 1rem;
font-size: 0.7rem;
color: var(--color-nier-mid);
font-family: var(--font-terminal-retro);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
/* Animation */
animation: indicatorFade 0.5s ease-out 2.3s forwards;
}
@keyframes indicatorFade {
0% {
opacity: 0;
transform: translateY(5px);
}
100% {
opacity: 0; /* Still hidden until hover */
transform: translateY(0);
}
}
/* Status Colors */
.status-completed { color: var(--color-nier-accent); }
.status-active { color: #4a7c59; }
.status-experimental { color: #7c5a4a; }
.status-archived { color: var(--color-nier-mid); }
/* Redacted Styles */
.project-card.redacted {
cursor: not-allowed;
/* Special animation for redacted cards */
animation: redactedMaterialize 1.5s ease-out forwards;
}
@keyframes redactedMaterialize {
0% {
opacity: 0;
transform: scale(0.95);
filter: blur(3px);
}
30% {
opacity: 0.4;
transform: scale(0.98);
filter: blur(2px);
}
60% {
opacity: 0.7;
transform: scale(1.01);
filter: blur(1px);
}
100% {
opacity: 1;
transform: scale(1);
filter: blur(0px);
}
}
.project-card.redacted:hover {
transform: none;
border-color: var(--color-nier-border);
}
.redacted-background {
position: relative;
min-height: 300px;
background-color: var(--color-nier-checkered-bg);
background-size: 0.5rem 0.5rem;
background-image:
linear-gradient(45deg, var(--color-nier-mid) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-nier-mid) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-nier-mid) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-nier-mid) 75%);
}
.redacted-content {
filter: blur(1px);
user-select: none;
opacity: 0.6;
}
.redacted-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-nier-accent);
color: var(--color-nier-text-light);
padding: 1.5rem 2rem;
border: 2px solid var(--color-nier-text-light);
text-align: center;
z-index: 10;
/* Animation */
opacity: 0;
animation: redactedOverlaySlam 0.6s ease-out 1.2s forwards;
}
@keyframes redactedOverlaySlam {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8) rotate(-2deg);
}
50% {
opacity: 0.9;
transform: translate(-50%, -50%) scale(1.1) rotate(1deg);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1) rotate(0deg);
}
}
/* Static overlay animation for redacted cards */
.static-overlay {
opacity: 0;
animation: staticFadeIn 0.8s ease-out 1.0s forwards;
}
@keyframes staticFadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Corner accent animation */
.corner-accent {
opacity: 0;
animation: cornerAccentGlow 0.8s ease-out 2.5s forwards;
}
@keyframes cornerAccentGlow {
0% {
opacity: 0;
transform: scale(0.5);
}
50% {
opacity: 0.8;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Glitch layers animation for added cyberpunk effect */
.glitch-layer {
opacity: 0;
animation: glitchLayerFlicker 0.1s ease-in-out forwards;
}
.glitch-layer-1 { animation-delay: 0.8s; }
.glitch-layer-2 { animation-delay: 1.0s; }
.glitch-layer-3 { animation-delay: 1.2s; }
@keyframes glitchLayerFlicker {
0%, 100% { opacity: 0; }
50% { opacity: 0.1; }
}
/* Responsive Design */
@media (max-width: 768px) {
.detail-label {
min-width: 100px;
}
.access-section {
flex-direction: column;
}
.access-btn {
flex: none;
}
/* Faster animations on mobile */
.project-card {
animation-duration: 1s;
}
.project-header {
animation-duration: 0.6s;
animation-delay: 0.2s;
}
.project-status {
animation-delay: 0.4s;
}
.project-title {
animation-delay: 0.5s;
}
.project-body {
animation-delay: 0.6s;
}
}
/* Modal Animations */
.modal-overlay {
opacity: 0;
animation: modalOverlayFadeIn 0.4s ease-out forwards;
}
@keyframes modalOverlayFadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.modal-container {
opacity: 0;
transform: scale(0.9) translateY(-20px);
animation: modalContainerSlideIn 0.5s ease-out 0.1s forwards;
}
@keyframes modalContainerSlideIn {
0% {
opacity: 0;
transform: scale(0.9) translateY(-20px);
filter: blur(1px);
}
60% {
opacity: 0.9;
transform: scale(1.02) translateY(-5px);
filter: blur(0px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0px);
}
}
.modal-header {
opacity: 0;
transform: translateY(-10px);
animation: modalHeaderFadeIn 0.4s ease-out 0.3s forwards;
}
@keyframes modalHeaderFadeIn {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
opacity: 0;
transform: translateY(15px);
animation: modalContentFadeUp 0.5s ease-out 0.4s forwards;
}
@keyframes modalContentFadeUp {
0% {
opacity: 0;
transform: translateY(15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.modal-section {
opacity: 0;
transform: translateX(-10px);
animation: modalSectionSlideIn 0.3s ease-out forwards;
}
.modal-section:nth-child(1) { animation-delay: 0.6s; }
.modal-section:nth-child(2) { animation-delay: 0.7s; }
.modal-section:nth-child(3) { animation-delay: 0.8s; }
.modal-section:nth-child(4) { animation-delay: 0.9s; }
.modal-section:nth-child(5) { animation-delay: 1.0s; }
@keyframes modalSectionSlideIn {
0% {
opacity: 0;
transform: translateX(-10px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.modal-close-btn {
opacity: 0;
transform: scale(0.8);
animation: modalCloseBtnPop 0.3s ease-out 0.5s forwards;
}
@keyframes modalCloseBtnPop {
0% {
opacity: 0;
transform: scale(0.8);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Modal exit animations */
.modal-overlay.closing {
animation: modalOverlayFadeOut 0.3s ease-in forwards;
}
@keyframes modalOverlayFadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.modal-container.closing {
animation: modalContainerSlideOut 0.3s ease-in forwards;
}
@keyframes modalContainerSlideOut {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.95) translateY(-10px);
filter: blur(1px);
}
}
@media (prefers-reduced-motion: reduce) {
.project-card,
.project-header,
.project-status,
.project-title,
.project-body,
.project-detail,
.tech-stack,
.tech-item,
.access-section,
.access-btn,
.click-indicator,
.corner-accent,
.glitch-layer,
.static-overlay,
.redacted-overlay {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.project-card::before {
display: none;
}
}

View File

@@ -0,0 +1,144 @@
<div class="project-card border-2 border-nier-border bg-nier-bg relative overflow-hidden transition-all duration-300 group"
[class.redacted]="project().isRedacted"
[class.glitch-enabled]="!project().isRedacted"
(click)="onCardClick()"
(mouseenter)="onHover()"
(mouseleave)="onLeave()">
<!-- Glitch overlay layers -->
<div class="glitch-layer glitch-layer-1"></div>
<div class="glitch-layer glitch-layer-2"></div>
<div class="glitch-layer glitch-layer-3"></div>
<!-- Static noise overlay for redacted cards -->
@if (project().isRedacted) {
<div class="static-overlay"></div>
}
<!-- Regular Project Content -->
@if (!project().isRedacted) {
<!-- Project Header -->
<div class="project-header bg-nier-dark text-nier-light p-4 border-b-2 border-nier-border relative overflow-hidden">
<!-- Header glitch line -->
<div class="header-glitch-line"></div>
<div class="project-status font-terminal-retro text-sm mb-2 tracking-wider relative z-10"
[class]="statusClass()">
{{ project().status }}
</div>
<div class="project-title font-noto-jp text-xl font-normal tracking-wider relative z-10">
{{ project().title }}
</div>
</div>
<!-- Project Details -->
<div class="project-body p-6 space-y-3 relative">
<!-- Body scan line -->
<div class="body-scan-line"></div>
<div class="project-detail glitch-text">
<span class="detail-label">CLASSIFICATION:</span>
<span class="detail-value">{{ project().classification }}</span>
</div>
<div class="project-detail glitch-text">
<span class="detail-label">OBJECTIVE:</span>
<span class="detail-value">{{ project().objective }}</span>
</div>
<div class="project-detail glitch-text">
<span class="detail-label">STATUS:</span>
<span class="detail-value">{{ project().statusDescription }}</span>
</div>
@if (project().techStack && project().techStack.length > 0) {
<div class="tech-stack pt-4 mt-4 border-t border-nier-border">
<div class="detail-label mb-3">TECH_STACK:</div>
<div class="tech-grid">
@for (tech of project().techStack; track tech; let i = $index) {
<span class="tech-item glitch-tech"
[style.animation-delay]="getTechItemDelay(i)">{{ tech }}</span>
}
</div>
</div>
}
</div>
<!-- Action Buttons -->
@if (project().demoUrl || project().codeUrl) {
<div class="access-section relative">
<!-- Button area glitch -->
<div class="button-glitch-overlay"></div>
@if (project().demoUrl) {
<button class="access-btn glitch-button"
(click)="openLink($event, project().demoUrl!)"
(mouseenter)="triggerButtonGlitch($event)">
<span class="button-text">EXECUTE_DEMO</span>
<div class="button-glitch-layer"></div>
</button>
}
@if (project().codeUrl) {
<button class="access-btn glitch-button"
(click)="openLink($event, project().codeUrl!)"
(mouseenter)="triggerButtonGlitch($event)">
<span class="button-text">ACCESS_CODE</span>
<div class="button-glitch-layer"></div>
</button>
}
</div>
}
<!-- Enhanced click indicator -->
<div class="click-indicator">
<span class="glitch-click-text">CLICK_FOR_CASE_STUDY</span>
</div>
}
<!-- Redacted Project Content -->
@if (project().isRedacted) {
<div class="redacted-background">
<div class="redacted-content">
<!-- Redacted Header -->
<div class="bg-nier-dark text-nier-light p-4 border-b-2 border-nier-border">
<div class="font-terminal-retro text-sm mb-2 redacted-status">[████████████]</div>
<div class="font-noto-jp text-xl redacted-title">████████████.EXE</div>
</div>
<!-- Redacted Details -->
<div class="p-6 space-y-3">
<div class="project-detail">
<span class="detail-label">████████████:</span>
<span class="detail-value">████████████████████</span>
</div>
<div class="project-detail">
<span class="detail-label">█████████:</span>
<span class="detail-value">████████████████████████████</span>
</div>
<div class="project-detail">
<span class="detail-label">██████:</span>
<span class="detail-value">████████████████</span>
</div>
<div class="tech-stack pt-4 mt-4 border-t border-nier-border">
<div class="detail-label mb-3">██████████:</div>
<div class="tech-grid">
@for (item of redactedTechItems(); track $index) {
<span class="tech-item redacted-tech">████</span>
}
</div>
</div>
</div>
</div>
<!-- Enhanced redacted overlay -->
<div class="redacted-overlay">
<div class="text-2xl font-noto-jp tracking-wider glitch-redacted-text">REDACTED</div>
<div class="text-sm mt-2 opacity-80">TO_BE_COMING</div>
<!-- Redacted warning lines -->
<div class="redacted-warning-lines"></div>
</div>
</div>
}
<!-- Corner accent for all cards -->
<div class="corner-accent"></div>
</div>

View File

@@ -0,0 +1,205 @@
// project-card.component.ts - Updated version with animation support
import {
Component,
input,
output,
computed,
OnInit,
ElementRef,
} from '@angular/core';
export interface Project {
id: string;
title: string;
status: string;
classification: string;
objective: string;
statusDescription: string;
techStack: string[];
demoUrl?: string;
codeUrl?: string;
isRedacted: boolean;
caseStudy?: {
title: string;
sections: CaseStudySection[];
};
}
export interface CaseStudySection {
title: string;
content: string;
}
@Component({
selector: 'app-project-card',
standalone: true,
templateUrl: './project-card.component.html',
styleUrl: './project-card.component.css',
})
export class ProjectCardComponent implements OnInit {
// Angular 19 signals
project = input.required<Project>();
caseStudyOpen = output<Project>();
constructor(private elementRef: ElementRef) {}
ngOnInit() {
// Add data attribute for potential card-specific targeting
this.elementRef.nativeElement.setAttribute(
'data-project-id',
this.project().id,
);
// Trigger any additional load effects if needed
this.initializeCardAnimations();
}
// Computed signals
statusClass = computed(() => {
const statusMap: { [key: string]: string } = {
'[MISSION_COMPLETED]': 'status-completed',
'[MISSION_ACTIVE]': 'status-active',
'[EXPERIMENTAL]': 'status-experimental',
'[ARCHIVED]': 'status-archived',
};
return statusMap[this.project().status] || '';
});
redactedTechItems = computed(() => {
// Return array for *ngFor to create random number of redacted tech items
const count = Math.floor(Math.random() * 3) + 3; // 3-5 items
return Array.from({ length: count }, (_, i) => i);
});
// Method to calculate staggered animation delays for tech items
getTechItemDelay(index: number): string {
const baseDelay = 1.6; // Base delay in seconds
const staggerDelay = 0.1; // Delay between each item
return `${baseDelay + index * staggerDelay}s`;
}
onCardClick(): void {
if (!this.project().isRedacted && this.project().caseStudy) {
this.caseStudyOpen.emit(this.project());
}
}
openLink(event: Event, url: string): void {
event.stopPropagation();
window.open(url, '_blank');
}
// New glitch effect methods (reduced frequency)
onHover(): void {
if (!this.project().isRedacted) {
// Reduced chance for glitch effects (only 20% chance vs 70% before)
if (Math.random() > 0.8) {
this.triggerRandomGlitch();
}
}
}
onLeave(): void {
// Clean up any ongoing effects if needed
}
triggerButtonGlitch(event: Event): void {
const button = event.target as HTMLElement;
// Add temporary glitch class
button.classList.add('glitch-active');
// Remove after animation
setTimeout(() => {
button.classList.remove('glitch-active');
}, 500);
}
private initializeCardAnimations(): void {
// Optional: Add any JavaScript-based animation initialization
// Most animations are handled by CSS, but you could add specific logic here
if (this.project().isRedacted) {
// Add extra static effect for redacted cards
setTimeout(() => {
this.triggerStaticBurst();
}, 1500);
}
}
private triggerRandomGlitch(): void {
// Only text scramble effect now (removed color glitch)
this.triggerTextScramble();
}
private triggerTextScramble(): void {
// Find text elements and briefly scramble them (reduced frequency)
const textElements = this.elementRef.nativeElement.querySelectorAll(
'.glitch-text .detail-value',
);
// Only scramble one random element instead of all
if (textElements.length > 0) {
const randomIndex = Math.floor(Math.random() * textElements.length);
this.scrambleText(textElements[randomIndex] as HTMLElement);
}
}
private scrambleText(element: HTMLElement): void {
const originalText = element.textContent || '';
const chars = '▓▒░█▄▀■□▪▫';
// Briefly show scrambled text (reduced scrambles)
let scrambleCount = 0;
const maxScrambles = 2; // Reduced from 3 to 2
const scrambleInterval = setInterval(() => {
if (scrambleCount >= maxScrambles) {
element.textContent = originalText;
clearInterval(scrambleInterval);
return;
}
// Create scrambled version (less aggressive scrambling)
const scrambled = originalText
.split('')
.map((char) =>
Math.random() > 0.85 // Reduced from 0.7 to 0.85 (less chars affected)
? chars[Math.floor(Math.random() * chars.length)]
: char,
)
.join('');
element.textContent = scrambled;
scrambleCount++;
}, 60); // Slightly slower scrambling (50ms -> 60ms)
}
// Removed triggerColorGlitch method entirely
// Method to generate glitch-style loading text
generateGlitchText(text: string): string {
const glitchChars = '▓▒░█▄▀■□▪▫◆◇◈◉●○';
return text
.split('')
.map((char) =>
Math.random() > 0.8
? glitchChars[Math.floor(Math.random() * glitchChars.length)]
: char,
)
.join('');
}
// Method for redacted card static effect
triggerStaticBurst(): void {
if (this.project().isRedacted) {
const staticOverlay = this.elementRef.nativeElement.querySelector(
'.static-overlay',
) as HTMLElement;
if (staticOverlay) {
staticOverlay.style.animation = 'none';
staticOverlay.offsetHeight; // Trigger reflow
staticOverlay.style.animation = 'staticBurst 0.5s ease-out';
}
}
}
}

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-project',
imports: [],
templateUrl: './project.component.html',
styleUrl: './project.component.css'
})
export class ProjectComponent {
}

View File

@@ -0,0 +1,30 @@
.animate-scan {
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.1), transparent);
animation: scan 3s infinite;
}
@keyframes scan {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-fade-in-line {
animation: fadeInLine 0.8s forwards;
}
@keyframes fadeInLine {
to { opacity: 1; }
}
/* Responsive grid for project cards */
.grid-project-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
}
@media (max-width: 768px) {
.grid-project-cards {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,76 @@
<section class="border-b-1 h-screen flex items-top justify-top checkered-bg p-6 relative">
<app-section-title title="EXECUTE // DIRECTORY" />
<!-- project-list.component.html -->
<section class="min-h-screen bg-nier-bg checkered-background">
<!-- Section title with padding instead of margin to avoid white space -->
<div class="pt-8 pl-8 pb-8 bg-nier-bg checkered-background">
<app-section-title title="EXECUTE // DIRECTORY" />
</div>
<div class="max-w-6xl mx-auto p-6">
<!-- Terminal Header -->
<div class="checkered-background border-2 border-nier-border p-8 mb-8 font-terminal-nier relative overflow-hidden">
<!-- Scan line animation -->
<div class="absolute top-0 left-0 w-full h-full opacity-10 animate-scan"></div>
<div class="text-base mb-2 font-terminal">>> ACCESSING PROJECT DATABASE...</div>
<div class="my-6">
<div class="w-full h-6 border-2 border-nier-accent bg-nier-bg relative overflow-hidden">
<div class="h-full bg-nier-dark transition-all duration-[3000ms] ease-out"
[class.w-full]="isLoading()"
[class.w-0]="!isLoading()"></div>
</div>
</div>
<div class="text-base mb-2 font-terminal">>> CONNECTION ESTABLISHED</div>
<div class="text-base mb-2 font-terminal">>> DISPLAYING ARCHIVED MISSIONS</div>
</div>
<!-- Directory Tree -->
<div class="bg-nier-dark text-nier-light border-2 border-nier-accent p-6 mb-8 font-terminal text-sm">
<div class="font-noto-jp text-xl mb-4 border-b border-nier-mid pb-2">SYSTEM DIRECTORY</div>
<div class="space-y-1">
@for (line of directoryLines(); track $index) {
<div class="opacity-0 animate-fade-in-line"
[style.animation-delay]="($index * 300) + 'ms'">
{{ line }}
</div>
}
</div>
</div>
<!-- Projects Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
@for (project of projects(); track project.id) {
<app-project-card
[project]="project"
(caseStudyOpen)="openCaseStudy($event)" />
}
</div>
<!-- Footer -->
<div class="text-center p-6 border-t-2 border-nier-border font-terminal-retro text-sm text-nier-mid">
>>> END_OF_DIRECTORY_LISTING<br>
>>> TOTAL_PROJECTS: {{ totalProjects() }} | ACTIVE: {{ activeProjects() }} | REDACTED: {{ redactedProjects() }}
</div>
</div>
<!-- Case Study Modal -->
@if (selectedProject()) {
<div class="fixed inset-0 z-50 overflow-y-auto p-4 flex items-start justify-center transition-all duration-300"
style="background-color: rgba(41, 41, 37, 0.9);"
(click)="closeCaseStudy($event)">
<div class="bg-nier-bg border-4 border-nier-accent max-w-4xl w-full max-h-[90vh] overflow-y-auto relative mt-8">
<div class="bg-nier-dark text-nier-light p-6 border-b-2 border-nier-border sticky top-0 z-10">
<div class="font-noto-jp text-3xl mb-2">{{ selectedProject()?.caseStudy?.title }}</div>
<button class="absolute top-4 right-6 bg-transparent border-2 border-nier-light text-nier-light w-10 h-10 cursor-pointer font-terminal text-xl hover:bg-nier-light hover:text-nier-dark transition-colors duration-300"
(click)="closeCaseStudy()">×</button>
</div>
<div class="p-8">
@for (section of selectedProject()?.caseStudy?.sections; track section.title) {
<div class="mb-8 pb-6 border-b border-nier-border last:border-b-0">
<div class="font-noto-jp text-xl mb-4 text-nier-accent">{{ section.title }}</div>
<div class="leading-relaxed mb-4" [innerHTML]="section.content"></div>
</div>
}
</div>
</div>
</div>
}
</section>

View File

@@ -1,10 +1,172 @@
import { Component } from '@angular/core';
import {SectionTitleComponent} from "../../../../shared/ui/section-title/section-title.component";
// project-list.component.ts
import { Component, signal, computed, effect, OnInit } from '@angular/core';
import {
ProjectCardComponent,
Project,
CaseStudySection,
} from '../project-card/project-card.component';
import { SectionTitleComponent } from '../../../../shared/ui/section-title/section-title.component';
@Component({
selector: 'app-projects-list',
imports: [SectionTitleComponent],
selector: 'app-project-list',
standalone: true,
imports: [ProjectCardComponent, SectionTitleComponent],
templateUrl: './projects-list.component.html',
styleUrl: './projects-list.component.css',
})
export class ProjectsListComponent {}
export class ProjectListComponent implements OnInit {
// Signals
projects = signal<Project[]>([]);
selectedProject = signal<Project | null>(null);
isLoading = signal(false);
directoryLines = signal([
'EXECUTE//DIRECTORY/',
'├── WEB_APPLICATIONS/',
'│ ├── portfolio_system.exe',
'│ └── [CLASSIFIED].exe',
'└── EXPERIMENTAL_PROJECTS/',
]);
// Computed signals
totalProjects = computed(() => this.projects().length);
activeProjects = computed(
() =>
this.projects().filter(
(p) => !p.isRedacted && p.status === '[MISSION_ACTIVE]',
).length,
);
redactedProjects = computed(
() => this.projects().filter((p) => p.isRedacted).length,
);
// Effect to handle body scroll when modal opens/closes
constructor() {
effect(() => {
document.body.style.overflow = this.selectedProject() ? 'hidden' : 'auto';
});
}
ngOnInit(): void {
this.loadProjects();
this.startLoadingAnimation();
}
private startLoadingAnimation(): void {
this.isLoading.set(true);
// Stop animation after 3 seconds
setTimeout(() => {
this.isLoading.set(false);
}, 3000);
}
private loadProjects(): void {
const projectsData: Project[] = [
{
id: 'portfolio',
title: 'PORTFOLIO_SYSTEM.EXE',
status: '[MISSION_ACTIVE]',
classification: 'Personal Showcase Platform',
objective: 'Professional presentation interface with NieR aesthetic',
statusDescription: 'LIVE | CONTINUOUS_UPDATE',
techStack: ['ANGULAR', 'TYPESCRIPT', 'TAILWIND', 'VERCEL'],
demoUrl: 'https://adambenyekkoudev.vercel.app/',
codeUrl: 'https://github.com/adam-benyekkou/angular_portfolio',
isRedacted: false,
caseStudy: {
title: 'PORTFOLIO_SYSTEM.EXE',
sections: [
{
title: 'PROJECT_OVERVIEW',
content: `A NieR: Automata inspired personal portfolio showcasing development projects and skills.
The design captures the game's distinctive UI aesthetic while maintaining modern web standards
and accessibility. Built to stand out from typical developer portfolios while remaining
professional and functional.`,
},
{
title: 'DESIGN_PHILOSOPHY',
content: `Recreated the authentic NieR: Automata interface with parchment backgrounds,
geometric layouts, and terminal-style typography. The design system uses CSS custom properties
for theme consistency and implements dark/light mode switching that maintains
the aesthetic in both contexts. Every element is carefully crafted to feel like
it belongs in the game's world while serving a practical purpose.`,
},
{
title: 'TECHNICAL_IMPLEMENTATION',
content: `Built with Angular 19 and Tailwind CSS using a custom design system.
The architecture follows Angular best practices with standalone components,
lazy loading, and optimized build processes. Implemented responsive grid layouts,
smooth animations, and interactive elements that respond to user interactions
while maintaining the game's UI feel. Special attention was paid to performance
optimization and accessibility standards.`,
},
{
title: 'CHALLENGES_SOLVED',
content: `<strong>Challenge 1:</strong> Balancing aesthetic authenticity with web accessibility<br>
<strong>Solution:</strong> Created high-contrast color schemes that pass WCAG guidelines<br><br>
<strong>Challenge 2:</strong> Making retro aesthetics work on modern devices<br>
<strong>Solution:</strong> Responsive design system with flexible typography and layouts<br><br>
<strong>Challenge 3:</strong> Performance with complex visual effects<br>
<strong>Solution:</strong> CSS-only animations and optimized asset loading`,
},
{
title: 'PERFORMANCE_METRICS',
content: `• Lighthouse score: <strong>95+</strong><br>
• First contentful paint: <strong>< 1.2s</strong><br>
• Accessibility score: <strong>98%</strong><br>
• Mobile optimization: <strong>Perfect responsive design</strong><br>
• Bundle size: <strong>< 500KB gzipped</strong><br>
• SEO optimization: <strong>100%</strong>`,
},
],
},
},
// Redacted projects
{
id: 'redacted-1',
title: 'REDACTED_PROJECT_1',
status: '[REDACTED]',
classification: '',
objective: '',
statusDescription: '',
techStack: [],
isRedacted: true,
},
{
id: 'redacted-2',
title: 'REDACTED_PROJECT_2',
status: '[REDACTED]',
classification: '',
objective: '',
statusDescription: '',
techStack: [],
isRedacted: true,
},
{
id: 'redacted-3',
title: 'REDACTED_PROJECT_3',
status: '[REDACTED]',
classification: '',
objective: '',
statusDescription: '',
techStack: [],
isRedacted: true,
},
];
this.projects.set(projectsData);
}
openCaseStudy(project: Project): void {
this.selectedProject.set(project);
}
closeCaseStudy(event?: MouseEvent): void {
if (event && event.target !== event.currentTarget) {
return;
}
this.selectedProject.set(null);
}
}