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 @@