diff --git a/.dockerignore b/.dockerignore index b1fd22a..257f535 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,7 @@ dist .npm-cache coverage .eslintcache +src/assets/optimized # Lokale Log- und Debug-Dateien npm-debug.log* diff --git a/.gitignore b/.gitignore index 7f227c5..29f1c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # Generierte Build- und Test-Ausgaben /dist /coverage +/src/assets/optimized # Betriebssystem-Dateileichen .DS_Store diff --git a/angular.json b/angular.json index 266edb7..8efceeb 100644 --- a/angular.json +++ b/angular.json @@ -26,8 +26,8 @@ "assets": [ { "glob": "**/*", - "input": "src/assets", - "output": "assets" + "input": "src/assets/optimized", + "output": "assets/optimized" }, { "glob": "**/*", @@ -108,8 +108,8 @@ "assets": [ { "glob": "**/*", - "input": "src/assets", - "output": "assets" + "input": "src/assets/optimized", + "output": "assets/optimized" }, { "glob": "**/*", diff --git a/package.json b/package.json index 1ae918b..236f8f8 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,11 @@ "fix": "npm run version:place && npm run lint:fix && npm run text:fix && npm run format", "------ BUILD": "", "generate:static-files": "node scripts/generate-static-files.cjs", - "generate:assets": "npm run generate:static-files", - "compress:assets": "node scripts/compress-assets.cjs", - "build": "npm run generate:assets && ng build --configuration production && npm run compress:assets && node scripts/copy-static-root-files.cjs", + "generate:optimized-assets": "node scripts/generate-optimized-assets.cjs", + "generate:assets": "npm run generate:static-files && npm run generate:optimized-assets", + "build": "npm run generate:assets && ng build --configuration production && node scripts/copy-static-root-files.cjs", "------ STARTUP": "", - "test": "ng test", + "test": "npm run generate:assets && ng test", "dev": "npm run generate:assets && ng serve", "start": "node dist/server/server.mjs", "------ CONFIGS": "", diff --git a/scripts/compress-assets.cjs b/scripts/compress-assets.cjs deleted file mode 100644 index d92e785..0000000 --- a/scripts/compress-assets.cjs +++ /dev/null @@ -1,150 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const sharp = require('sharp'); - -const distAssetRoots = [ - path.join(process.cwd(), 'dist', 'assets'), - path.join(process.cwd(), 'dist', 'browser', 'assets'), -]; - -const losslessTransformers = { - '.jpg': (pipeline) => - pipeline.jpeg({ - quality: 90, - mozjpeg: true, - chromaSubsampling: '4:4:4', - optimizeScans: true, - }), - '.jpeg': (pipeline) => - pipeline.jpeg({ - quality: 90, - mozjpeg: true, - chromaSubsampling: '4:4:4', - optimizeScans: true, - }), - '.png': (pipeline) => pipeline.png({ compressionLevel: 9, effort: 10 }), - '.gif': (pipeline) => pipeline.gif({ effort: 10, colours: 256 }), - '.webp': (pipeline) => pipeline.webp({ lossless: true, effort: 6 }), - '.avif': (pipeline) => pipeline.avif({ lossless: true, effort: 9 }), - '.tif': (pipeline) => pipeline.tiff({ compression: 'lzw' }), - '.tiff': (pipeline) => pipeline.tiff({ compression: 'lzw' }), -}; - -const imageExtensions = new Set([ - '.png', - '.jpg', - '.jpeg', - '.webp', - '.avif', - '.gif', - '.svg', - '.tif', - '.tiff', - '.bmp', -]); - -const skippedNoLosslessEncoder = new Set(['.svg', '.bmp']); - -function getAllFilesRecursively(directory) { - const entries = fs.readdirSync(directory, { withFileTypes: true }); - const files = []; - - for (const entry of entries) { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - files.push(...getAllFilesRecursively(fullPath)); - continue; - } - - if (entry.isFile()) { - files.push(fullPath); - } - } - - return files; -} - -async function compressLossless(filePath) { - const ext = path.extname(filePath).toLowerCase(); - const transform = losslessTransformers[ext]; - - if (!transform) { - return { status: 'unsupported', beforeBytes: 0, afterBytes: 0 }; - } - - const beforeBuffer = fs.readFileSync(filePath); - const beforeBytes = beforeBuffer.byteLength; - const optimizedBuffer = await transform(sharp(beforeBuffer)).toBuffer(); - const afterBytes = optimizedBuffer.byteLength; - - if (afterBytes < beforeBytes) { - fs.writeFileSync(filePath, optimizedBuffer); - return { status: 'optimized', beforeBytes, afterBytes }; - } - - return { status: 'unchanged', beforeBytes, afterBytes: beforeBytes }; -} - -async function run() { - const existingRoots = distAssetRoots - .filter((rootPath) => fs.existsSync(rootPath)) - .filter((rootPath, index, list) => list.indexOf(rootPath) === index); - - if (existingRoots.length === 0) { - console.log('Asset compression skipped: no dist asset directory found.'); - return; - } - - const imageFiles = existingRoots - .flatMap((rootPath) => getAllFilesRecursively(rootPath)) - .filter((filePath) => imageExtensions.has(path.extname(filePath).toLowerCase())); - - let optimizedCount = 0; - let unchangedCount = 0; - let skippedCount = 0; - let totalSavedBytes = 0; - - for (const filePath of imageFiles) { - const ext = path.extname(filePath).toLowerCase(); - if (skippedNoLosslessEncoder.has(ext)) { - skippedCount += 1; - console.log( - `Skip (no lossless encoder configured): ${path.relative(process.cwd(), filePath)}`, - ); - continue; - } - - try { - const result = await compressLossless(filePath); - if (result.status === 'optimized') { - optimizedCount += 1; - const savedBytes = result.beforeBytes - result.afterBytes; - totalSavedBytes += savedBytes; - console.log(`Optimized: ${path.relative(process.cwd(), filePath)} (-${savedBytes} bytes)`); - } else if (result.status === 'unchanged') { - unchangedCount += 1; - console.log(`Unchanged: ${path.relative(process.cwd(), filePath)}`); - } else { - skippedCount += 1; - } - } catch (error) { - skippedCount += 1; - const message = error instanceof Error ? error.message : String(error); - console.warn(`Skip (error): ${path.relative(process.cwd(), filePath)} (${message})`); - } - } - - console.log( - `Asset compression complete. Optimized: ${optimizedCount}, unchanged: ${unchangedCount}, skipped: ${skippedCount}, saved: ${totalSavedBytes} bytes.`, - ); -} - -run() - .then(() => { - process.exit(0); - }) - .catch((error) => { - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - console.error(`Asset compression failed: ${message}`); - process.exit(1); - }); diff --git a/scripts/generate-optimized-assets.cjs b/scripts/generate-optimized-assets.cjs new file mode 100644 index 0000000..b5ea10f --- /dev/null +++ b/scripts/generate-optimized-assets.cjs @@ -0,0 +1,163 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const sharp = require('sharp'); + +const sourceRoot = path.join(process.cwd(), 'src', 'assets', 'unoptimized'); +const targetRoot = path.join(process.cwd(), 'src', 'assets', 'optimized'); + +const rasterExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tif', '.tiff']); +const passthroughExtensions = new Set(['.svg']); + +function collectFilesRecursively(directoryPath) { + if (!fs.existsSync(directoryPath)) { + return []; + } + + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + files.push(...collectFilesRecursively(fullPath)); + continue; + } + + if (entry.isFile()) { + files.push(fullPath); + } + } + + return files; +} + +function ensureCleanDirectory(directoryPath) { + fs.rmSync(directoryPath, { recursive: true, force: true }); + fs.mkdirSync(directoryPath, { recursive: true }); +} + +function getProfile(relativePath) { + const normalizedRelativePath = relativePath.split(path.sep).join('/').toLowerCase(); + + if (normalizedRelativePath === 'background.jpg' || normalizedRelativePath === 'background.jpeg') { + return { maxWidth: 1920, quality: 82, alphaQuality: 100 }; + } + + if (normalizedRelativePath.startsWith('logos/')) { + return { maxWidth: 640, quality: 90, alphaQuality: 92, nearLossless: true }; + } + + if (normalizedRelativePath.startsWith('portrait/')) { + return { maxWidth: 1600, quality: 84, alphaQuality: 92 }; + } + + return { maxWidth: 1600, quality: 82, alphaQuality: 92 }; +} + +async function writeOptimizedAsset(sourceFilePath) { + const relativePath = path.relative(sourceRoot, sourceFilePath); + const extension = path.extname(sourceFilePath).toLowerCase(); + const targetRelativePath = passthroughExtensions.has(extension) + ? relativePath + : relativePath.replace(/\.[^.]+$/, '.webp'); + const targetFilePath = path.join(targetRoot, targetRelativePath); + + fs.mkdirSync(path.dirname(targetFilePath), { recursive: true }); + + if (passthroughExtensions.has(extension)) { + fs.copyFileSync(sourceFilePath, targetFilePath); + return { + sourceRelativePath: relativePath, + targetRelativePath, + beforeBytes: fs.statSync(sourceFilePath).size, + afterBytes: fs.statSync(targetFilePath).size, + mode: 'copied', + }; + } + + const profile = getProfile(relativePath); + const inputBuffer = fs.readFileSync(sourceFilePath); + const inputBytes = inputBuffer.byteLength; + const image = sharp(inputBuffer, { animated: false }).rotate(); + const metadata = await image.metadata(); + + let pipeline = image; + if ((metadata.width ?? 0) > profile.maxWidth) { + pipeline = pipeline.resize({ + width: profile.maxWidth, + fit: 'inside', + withoutEnlargement: true, + }); + } + + const outputBuffer = await pipeline + .webp({ + quality: profile.quality, + alphaQuality: profile.alphaQuality, + effort: 6, + smartSubsample: true, + nearLossless: profile.nearLossless ?? false, + }) + .toBuffer(); + + fs.writeFileSync(targetFilePath, outputBuffer); + + return { + sourceRelativePath: relativePath, + targetRelativePath, + beforeBytes: inputBytes, + afterBytes: outputBuffer.byteLength, + mode: 'converted', + }; +} + +async function run() { + if (!fs.existsSync(sourceRoot)) { + console.log('Optimized asset generation skipped: no unoptimized asset directory found.'); + return; + } + + ensureCleanDirectory(targetRoot); + + const assetFiles = collectFilesRecursively(sourceRoot).filter((filePath) => { + const extension = path.extname(filePath).toLowerCase(); + return rasterExtensions.has(extension) || passthroughExtensions.has(extension); + }); + + let convertedCount = 0; + let copiedCount = 0; + let totalSavedBytes = 0; + + for (const assetFile of assetFiles) { + const result = await writeOptimizedAsset(assetFile); + const savedBytes = result.beforeBytes - result.afterBytes; + totalSavedBytes += savedBytes; + + if (result.mode === 'copied') { + copiedCount += 1; + console.log( + `Copied: ${result.sourceRelativePath} -> ${result.targetRelativePath} (${result.afterBytes} bytes)`, + ); + continue; + } + + convertedCount += 1; + console.log( + `Optimized: ${result.sourceRelativePath} -> ${result.targetRelativePath} (${savedBytes >= 0 ? `-${savedBytes}` : `+${Math.abs(savedBytes)}`} bytes)`, + ); + } + + console.log( + `Optimized asset generation complete. Converted: ${convertedCount}, copied: ${copiedCount}, net saved: ${totalSavedBytes} bytes.`, + ); +} + +run() + .then(() => { + process.exit(0); + }) + .catch((error) => { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(`Optimized asset generation failed: ${message}`); + process.exit(1); + }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7e03570..e102b86 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -7,5 +7,5 @@ import { RouterOutlet } from '@angular/router'; templateUrl: './app.component.html', }) export class AppComponent { - title: string = 'Angular-Portfolio-Seite'; + title: string = 'Lechner Julian'; } diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 6dff0d4..599435a 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -391,7 +391,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { ? Number.POSITIVE_INFINITY : portfolioEyebrow.getBoundingClientRect().top + window.scrollY; const isMobileViewport: boolean = window.innerWidth <= 700; - const mobileRevealDistance: number = window.innerHeight / 25; + const mobileRevealDistance: number = window.innerHeight / 15; const triggerDistance: number = isMobileViewport ? mobileRevealDistance : Math.max(portfolioTriggerY, 1); diff --git a/src/assets/background.jpg b/src/assets/unoptimized/background.jpg similarity index 100% rename from src/assets/background.jpg rename to src/assets/unoptimized/background.jpg diff --git a/src/assets/logos/sobit.png b/src/assets/unoptimized/logos/sobit.png similarity index 100% rename from src/assets/logos/sobit.png rename to src/assets/unoptimized/logos/sobit.png diff --git a/src/assets/portrait/culture.jpg b/src/assets/unoptimized/portrait/culture.jpg similarity index 100% rename from src/assets/portrait/culture.jpg rename to src/assets/unoptimized/portrait/culture.jpg diff --git a/src/assets/portrait/love.jpg b/src/assets/unoptimized/portrait/love.jpg similarity index 100% rename from src/assets/portrait/love.jpg rename to src/assets/unoptimized/portrait/love.jpg diff --git a/src/assets/portrait/me.png b/src/assets/unoptimized/portrait/me.png similarity index 100% rename from src/assets/portrait/me.png rename to src/assets/unoptimized/portrait/me.png diff --git a/src/assets/portrait/scuba.jpg b/src/assets/unoptimized/portrait/scuba.jpg similarity index 100% rename from src/assets/portrait/scuba.jpg rename to src/assets/unoptimized/portrait/scuba.jpg diff --git a/src/assets/portrait/trains.jpg b/src/assets/unoptimized/portrait/trains.jpg similarity index 100% rename from src/assets/portrait/trains.jpg rename to src/assets/unoptimized/portrait/trains.jpg diff --git a/src/assets/portrait/traveling.jpg b/src/assets/unoptimized/portrait/traveling.jpg similarity index 100% rename from src/assets/portrait/traveling.jpg rename to src/assets/unoptimized/portrait/traveling.jpg diff --git a/src/global.ts b/src/global.ts index 5685780..1669590 100644 --- a/src/global.ts +++ b/src/global.ts @@ -74,27 +74,27 @@ export const global: Global = { ], portraitHighlights: [ { - image: 'assets/portrait/me.png', + image: 'assets/optimized/portrait/me.webp', text: 'ME', }, { - image: 'assets/portrait/love.jpg', + image: 'assets/optimized/portrait/love.webp', text: 'My Love', }, { - image: 'assets/portrait/scuba.jpg', + image: 'assets/optimized/portrait/scuba.webp', text: 'Scuba Diving', }, { - image: 'assets/portrait/trains.jpg', + image: 'assets/optimized/portrait/trains.webp', text: 'Trains', }, { - image: 'assets/portrait/traveling.jpg', + image: 'assets/optimized/portrait/traveling.webp', text: 'Traveling', }, { - image: 'assets/portrait/culture.jpg', + image: 'assets/optimized/portrait/culture.webp', text: 'Culture', }, ], @@ -105,7 +105,7 @@ export const global: Global = { description: 'I currently work primarily for the viennese company SobIT GmbH. This company develops software for the healthcare industry. - Development of modern desktop, mobile, and web applications to support healthcare delivery.', languages: ['TypeScript', 'C#', 'WPF', 'YML', 'XAML'], - icon: 'assets/logos/sobit.png', + icon: 'assets/optimized/logos/sobit.webp', CircleIcon: false, }, { diff --git a/src/index.html b/src/index.html index 19fa158..21332f3 100644 --- a/src/index.html +++ b/src/index.html @@ -5,7 +5,7 @@ - + Lechner Julian diff --git a/src/styles.scss b/src/styles.scss index 6431954..4bc2b59 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -50,7 +50,7 @@ body { radial-gradient(circle at top left, rgba(217, 162, 140, 0.22), transparent 26%), radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.28), transparent 28%), linear-gradient(180deg, rgba(9, 9, 12, 0.4), rgba(8, 8, 11, 0.82)), - url('assets/background.jpg') center / cover fixed no-repeat; + url('assets/optimized/background.webp') center / cover fixed no-repeat; } a { @@ -916,6 +916,8 @@ hr { .footer-inner a { color: var(--text-primary); + margin-left: auto; + text-align: right; } @media (max-width: 900px) { @@ -1009,8 +1011,8 @@ hr { } .footer-inner { - flex-direction: column; - align-items: flex-start; + flex-direction: row; + align-items: center; } .project-icon {