fix: performance
@@ -14,6 +14,7 @@ dist
|
|||||||
.npm-cache
|
.npm-cache
|
||||||
coverage
|
coverage
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
src/assets/optimized
|
||||||
|
|
||||||
# Lokale Log- und Debug-Dateien
|
# Lokale Log- und Debug-Dateien
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
# Generierte Build- und Test-Ausgaben
|
# Generierte Build- und Test-Ausgaben
|
||||||
/dist
|
/dist
|
||||||
/coverage
|
/coverage
|
||||||
|
/src/assets/optimized
|
||||||
|
|
||||||
# Betriebssystem-Dateileichen
|
# Betriebssystem-Dateileichen
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "src/assets",
|
"input": "src/assets/optimized",
|
||||||
"output": "assets"
|
"output": "assets/optimized"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
@@ -108,8 +108,8 @@
|
|||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "src/assets",
|
"input": "src/assets/optimized",
|
||||||
"output": "assets"
|
"output": "assets/optimized"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
"fix": "npm run version:place && npm run lint:fix && npm run text:fix && npm run format",
|
"fix": "npm run version:place && npm run lint:fix && npm run text:fix && npm run format",
|
||||||
"------ BUILD": "",
|
"------ BUILD": "",
|
||||||
"generate:static-files": "node scripts/generate-static-files.cjs",
|
"generate:static-files": "node scripts/generate-static-files.cjs",
|
||||||
"generate:assets": "npm run generate:static-files",
|
"generate:optimized-assets": "node scripts/generate-optimized-assets.cjs",
|
||||||
"compress:assets": "node scripts/compress-assets.cjs",
|
"generate:assets": "npm run generate:static-files && npm run generate:optimized-assets",
|
||||||
"build": "npm run generate:assets && ng build --configuration production && npm run compress:assets && node scripts/copy-static-root-files.cjs",
|
"build": "npm run generate:assets && ng build --configuration production && node scripts/copy-static-root-files.cjs",
|
||||||
"------ STARTUP": "",
|
"------ STARTUP": "",
|
||||||
"test": "ng test",
|
"test": "npm run generate:assets && ng test",
|
||||||
"dev": "npm run generate:assets && ng serve",
|
"dev": "npm run generate:assets && ng serve",
|
||||||
"start": "node dist/server/server.mjs",
|
"start": "node dist/server/server.mjs",
|
||||||
"------ CONFIGS": "",
|
"------ CONFIGS": "",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -7,5 +7,5 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
title: string = 'Angular-Portfolio-Seite';
|
title: string = 'Lechner Julian';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
? Number.POSITIVE_INFINITY
|
? Number.POSITIVE_INFINITY
|
||||||
: portfolioEyebrow.getBoundingClientRect().top + window.scrollY;
|
: portfolioEyebrow.getBoundingClientRect().top + window.scrollY;
|
||||||
const isMobileViewport: boolean = window.innerWidth <= 700;
|
const isMobileViewport: boolean = window.innerWidth <= 700;
|
||||||
const mobileRevealDistance: number = window.innerHeight / 25;
|
const mobileRevealDistance: number = window.innerHeight / 15;
|
||||||
const triggerDistance: number = isMobileViewport
|
const triggerDistance: number = isMobileViewport
|
||||||
? mobileRevealDistance
|
? mobileRevealDistance
|
||||||
: Math.max(portfolioTriggerY, 1);
|
: Math.max(portfolioTriggerY, 1);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 5.0 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 809 KiB After Width: | Height: | Size: 809 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 9.2 MiB After Width: | Height: | Size: 9.2 MiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
@@ -74,27 +74,27 @@ export const global: Global = {
|
|||||||
],
|
],
|
||||||
portraitHighlights: [
|
portraitHighlights: [
|
||||||
{
|
{
|
||||||
image: 'assets/portrait/me.png',
|
image: 'assets/optimized/portrait/me.webp',
|
||||||
text: 'ME',
|
text: 'ME',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
image: 'assets/portrait/love.jpg',
|
image: 'assets/optimized/portrait/love.webp',
|
||||||
text: 'My Love',
|
text: 'My Love',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
image: 'assets/portrait/scuba.jpg',
|
image: 'assets/optimized/portrait/scuba.webp',
|
||||||
text: 'Scuba Diving',
|
text: 'Scuba Diving',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
image: 'assets/portrait/trains.jpg',
|
image: 'assets/optimized/portrait/trains.webp',
|
||||||
text: 'Trains',
|
text: 'Trains',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
image: 'assets/portrait/traveling.jpg',
|
image: 'assets/optimized/portrait/traveling.webp',
|
||||||
text: 'Traveling',
|
text: 'Traveling',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
image: 'assets/portrait/culture.jpg',
|
image: 'assets/optimized/portrait/culture.webp',
|
||||||
text: 'Culture',
|
text: 'Culture',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -105,7 +105,7 @@ export const global: Global = {
|
|||||||
description:
|
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.',
|
'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'],
|
languages: ['TypeScript', 'C#', 'WPF', 'YML', 'XAML'],
|
||||||
icon: 'assets/logos/sobit.png',
|
icon: 'assets/optimized/logos/sobit.webp',
|
||||||
CircleIcon: false,
|
CircleIcon: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="assets/portrait/me.png" />
|
<link rel="icon" type="image/webp" href="assets/optimized/portrait/me.webp" />
|
||||||
<title>Lechner Julian</title>
|
<title>Lechner Julian</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ body {
|
|||||||
radial-gradient(circle at top left, rgba(217, 162, 140, 0.22), transparent 26%),
|
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%),
|
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)),
|
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 {
|
a {
|
||||||
@@ -916,6 +916,8 @@ hr {
|
|||||||
|
|
||||||
.footer-inner a {
|
.footer-inner a {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -1009,8 +1011,8 @@ hr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-inner {
|
.footer-inner {
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-icon {
|
.project-icon {
|
||||||
|
|||||||