First Commit - Portfolio Page
@@ -0,0 +1,81 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Production
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
build
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
.vuepress/dist
|
||||||
|
.temp
|
||||||
|
.docusaurus
|
||||||
|
.serverless/
|
||||||
|
.fusebox/
|
||||||
|
.dynamodb/
|
||||||
|
.tern-port
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
supabase/.branches/
|
||||||
|
supabase/.temp/
|
||||||
|
|
||||||
|
# PWA files
|
||||||
|
**/public/workbox-*.js
|
||||||
|
**/public/sw.js
|
||||||
|
**/public/fallback-*.js
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.sass-cache/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
.history/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// If the loader is already loaded, just stop.
|
||||||
|
if (!self.define) {
|
||||||
|
let registry = {};
|
||||||
|
|
||||||
|
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||||
|
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||||
|
let nextDefineUri;
|
||||||
|
|
||||||
|
const singleRequire = (uri, parentUri) => {
|
||||||
|
uri = new URL(uri + ".js", parentUri).href;
|
||||||
|
return registry[uri] || (
|
||||||
|
|
||||||
|
new Promise(resolve => {
|
||||||
|
if ("document" in self) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = uri;
|
||||||
|
script.onload = resolve;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
nextDefineUri = uri;
|
||||||
|
importScripts(uri);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
let promise = registry[uri];
|
||||||
|
if (!promise) {
|
||||||
|
throw new Error(`Module ${uri} didn’t register its module`);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.define = (depsNames, factory) => {
|
||||||
|
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||||
|
if (registry[uri]) {
|
||||||
|
// Module is already loading or loaded.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let exports = {};
|
||||||
|
const require = depUri => singleRequire(depUri, uri);
|
||||||
|
const specialDeps = {
|
||||||
|
module: { uri },
|
||||||
|
exports,
|
||||||
|
require
|
||||||
|
};
|
||||||
|
registry[uri] = Promise.all(depsNames.map(
|
||||||
|
depName => specialDeps[depName] || require(depName)
|
||||||
|
)).then(deps => {
|
||||||
|
factory(...deps);
|
||||||
|
return exports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
define(['./workbox-4a2e5f00'], (function (workbox) { 'use strict';
|
||||||
|
|
||||||
|
self.skipWaiting();
|
||||||
|
workbox.clientsClaim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precacheAndRoute() method efficiently caches and responds to
|
||||||
|
* requests for URLs in the manifest.
|
||||||
|
* See https://goo.gl/S9QRab
|
||||||
|
*/
|
||||||
|
workbox.precacheAndRoute([{
|
||||||
|
"url": "registerSW.js",
|
||||||
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
|
}, {
|
||||||
|
"url": "index.html",
|
||||||
|
"revision": "0.vc3cdld58co"
|
||||||
|
}], {});
|
||||||
|
workbox.cleanupOutdatedCaches();
|
||||||
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
allowlist: [/^\/$/]
|
||||||
|
}));
|
||||||
|
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
|
||||||
|
"cacheName": "google-fonts-cache",
|
||||||
|
plugins: [new workbox.CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200]
|
||||||
|
})]
|
||||||
|
}), 'GET');
|
||||||
|
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
|
||||||
|
"cacheName": "gstatic-fonts-cache",
|
||||||
|
plugins: [new workbox.CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200]
|
||||||
|
})]
|
||||||
|
}), 'GET');
|
||||||
|
workbox.registerRoute(/^https:\/\/www\.googletagmanager\.com\/.*/i, new workbox.NetworkOnly(), 'GET');
|
||||||
|
|
||||||
|
}));
|
||||||
|
//# sourceMappingURL=sw.js.map
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de" class="bg-zinc-900">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Damjan Savić - JTL Integration Expert & Digital Solutions Consultant. Spezialisiert auf E-Commerce und digitale Transformation." />
|
||||||
|
<meta name="keywords" content="JTL, E-Commerce, Integration, Digital Solutions, Consulting" />
|
||||||
|
<meta name="author" content="Damjan Savić" />
|
||||||
|
<meta name="theme-color" content="#18181B" />
|
||||||
|
<title>Damjan Savić - JTL Integration Expert</title>
|
||||||
|
|
||||||
|
<!-- Critical Styles -->
|
||||||
|
<style>
|
||||||
|
/* Sofortiges Setzen der dunklen Farben */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background-color: #18181B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #18181B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verhindert weißes Aufblitzen */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body, #root {
|
||||||
|
background-color: #18181B;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Google Analytics -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-N0PZBL7X18"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-N0PZBL7X18');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Preload wichtiger Assets -->
|
||||||
|
<link rel="preload" href="/logo.png" as="image" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-zinc-900">
|
||||||
|
<div id="root" class="bg-zinc-900"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
def should_ignore(path):
|
||||||
|
ignore_paths = [
|
||||||
|
'.idea',
|
||||||
|
'dev-dist',
|
||||||
|
'node_modules',
|
||||||
|
'package-lock.json',
|
||||||
|
'External Libraries',
|
||||||
|
'Scratches and Consoles'
|
||||||
|
]
|
||||||
|
|
||||||
|
for ignore in ignore_paths:
|
||||||
|
if ignore in str(path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_directory_tree(path, prefix=""):
|
||||||
|
try:
|
||||||
|
# Verzeichnisname ausgeben
|
||||||
|
print(f"{prefix}📁 {os.path.basename(path)}")
|
||||||
|
|
||||||
|
# Präfix für Unterelemente
|
||||||
|
prefix_files = prefix + "├── "
|
||||||
|
prefix_last = prefix + "└── "
|
||||||
|
prefix_deeper = prefix + "│ "
|
||||||
|
|
||||||
|
# Alle Einträge sammeln und filtern
|
||||||
|
entries = [entry for entry in os.scandir(path) if not should_ignore(entry.path)]
|
||||||
|
|
||||||
|
# Sortieren: Ordner zuerst, dann Dateien
|
||||||
|
entries.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||||
|
|
||||||
|
# Durch alle Einträge iterieren
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
is_last = (i == len(entries) - 1)
|
||||||
|
current_prefix = prefix_last if is_last else prefix_files
|
||||||
|
|
||||||
|
if entry.is_file():
|
||||||
|
print(f"{current_prefix}📄 {entry.name}")
|
||||||
|
elif entry.is_dir():
|
||||||
|
print_directory_tree(entry.path, prefix_deeper if not is_last else prefix + " ")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fehler beim Lesen des Verzeichnisses {path}: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
portfolio_path = r"C:\Development\Damjan Savic\Portfolio"
|
||||||
|
print("\nVerzeichnisstruktur:")
|
||||||
|
print("="*50)
|
||||||
|
print_directory_tree(portfolio_path)
|
||||||
|
print("="*50)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "damjan-savic-portfolio",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdx-js/loader": "^3.1.0",
|
||||||
|
"@mdx-js/mdx": "^3.1.0",
|
||||||
|
"@mdx-js/react": "^3.1.0",
|
||||||
|
"@mdx-js/rollup": "^3.1.0",
|
||||||
|
"@rollup/plugin-babel": "^6.0.4",
|
||||||
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"i18next": "^23.10.1",
|
||||||
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
|
"lucide-react": "^0.263.1",
|
||||||
|
"next-mdx-remote": "^5.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-cookie-consent": "^9.0.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-ga4": "^2.1.0",
|
||||||
|
"react-helmet-async": "^2.0.4",
|
||||||
|
"react-i18next": "^14.1.0",
|
||||||
|
"react-intersection-observer": "^9.8.1",
|
||||||
|
"react-router-dom": "^6.29.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
|
"remark-frontmatter": "^5.0.0",
|
||||||
|
"remark-mdx-frontmatter": "^5.0.0",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"workbox-cacheable-response": "^7.0.0",
|
||||||
|
"workbox-core": "^7.0.0",
|
||||||
|
"workbox-expiration": "^7.0.0",
|
||||||
|
"workbox-precaching": "^7.0.0",
|
||||||
|
"workbox-routing": "^7.0.0",
|
||||||
|
"workbox-strategies": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
|
"@testing-library/react": "^14.2.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"@vitest/coverage-v8": "^1.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"vite-plugin-pwa": "^0.19.8",
|
||||||
|
"vitest": "^1.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - Damjan Savić</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #ff6b00;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #ff6b00;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #e65a00;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>You're Offline</h1>
|
||||||
|
<p>It seems you've lost your internet connection. Please check your connection and try again.</p>
|
||||||
|
<button onclick="window.location.reload()">Try Again</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 723 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
# Allow all crawlers
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Sitemap location
|
||||||
|
Sitemap: https://damjan-savic.com/sitemap.xml
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://damjan-savic.com/</loc>
|
||||||
|
<lastmod>2024-03-10</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://damjan-savic.com/about</loc>
|
||||||
|
<lastmod>2024-03-10</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://damjan-savic.com/portfolio</loc>
|
||||||
|
<lastmod>2024-03-10</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://damjan-savic.com/blog</loc>
|
||||||
|
<lastmod>2024-03-10</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://damjan-savic.com/contact</loc>
|
||||||
|
<lastmod>2024-03-10</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, Suspense } from 'react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import AppRoutes from './routes';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
|
import CookieBanner from './components/CookieBanner';
|
||||||
|
import PageTransition from './components/PageTransition';
|
||||||
|
import { logPageView } from './utils/analytics';
|
||||||
|
import './styles/scrollbar.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
logPageView();
|
||||||
|
|
||||||
|
// Stellt sicher, dass die Scrollbar von Anfang an konsistent ist
|
||||||
|
document.documentElement.classList.add('custom-scrollbar');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HelmetProvider>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="flex flex-col min-h-screen bg-background text-foreground">
|
||||||
|
<Layout>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PageTransition>
|
||||||
|
<AppRoutes />
|
||||||
|
</PageTransition>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Layout>
|
||||||
|
<CookieBanner />
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</HelmetProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Primary Colors */
|
||||||
|
--color-primary: 105 117 101; /* #697565 - Sage green */
|
||||||
|
--color-primary-dark: 24 28 20; /* #181C14 - Dark moss */
|
||||||
|
|
||||||
|
/* Secondary Colors */
|
||||||
|
--color-secondary: 24 28 20; /* #181C14 - Dark moss */
|
||||||
|
--color-secondary-gray: 60 61 55; /* #3C3D37 - Charcoal */
|
||||||
|
--color-secondary-fuzzy: 70 91 80; /* #465B50 - Dark sage */
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--color-text: 236 223 204; /* #ECDFCC - Cream */
|
||||||
|
--color-text-light: 105 117 101; /* #697565 - Sage */
|
||||||
|
|
||||||
|
/* Navigation Colors */
|
||||||
|
--nav-bg: 24 28 20; /* Dark moss */
|
||||||
|
--nav-bg-transparent: 24 28 20 / 0.95; /* Dark moss with transparency */
|
||||||
|
--nav-text: 236 223 204; /* Cream */
|
||||||
|
--nav-text-hover: 70 91 80; /* Dark sage */
|
||||||
|
--nav-text-active: 70 91 80; /* Dark sage */
|
||||||
|
--nav-border: 60 61 55; /* Charcoal */
|
||||||
|
--nav-shadow: 0 0 0 / 0.1;
|
||||||
|
|
||||||
|
/* System Colors */
|
||||||
|
--background: 24 28 20; /* Dark moss */
|
||||||
|
--foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--card: 30 34 26; /* Slightly lighter dark moss */
|
||||||
|
--card-foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--popover: 30 34 26; /* Slightly lighter dark moss */
|
||||||
|
--popover-foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--primary: 105 117 101; /* Sage */
|
||||||
|
--primary-foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--secondary: 60 61 55; /* Charcoal */
|
||||||
|
--secondary-foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--muted: 60 61 55; /* Charcoal */
|
||||||
|
--muted-foreground: 105 117 101; /* Sage */
|
||||||
|
|
||||||
|
--accent: 70 91 80; /* Dark sage - neuer dunkelgrüner Akzent */
|
||||||
|
--accent-foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--destructive: 180 60 60; /* Muted red for destructive actions */
|
||||||
|
--destructive-foreground: 236 223 204; /* Cream */
|
||||||
|
|
||||||
|
--border: 60 61 55; /* Charcoal */
|
||||||
|
--input: 60 61 55; /* Charcoal */
|
||||||
|
--ring: 70 91 80; /* Dark sage */
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Alert.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AlertProps {
|
||||||
|
variant?: 'success' | 'error';
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Alert: React.FC<AlertProps> = ({ variant = 'success', children }) => {
|
||||||
|
const variants = {
|
||||||
|
success: {
|
||||||
|
bg: 'bg-primary/10',
|
||||||
|
border: 'border-primary',
|
||||||
|
text: 'text-primary',
|
||||||
|
icon: <CheckCircle2 className="h-5 w-5" />
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: 'bg-red-500/10',
|
||||||
|
border: 'border-red-500',
|
||||||
|
text: 'text-red-400',
|
||||||
|
icon: <XCircle className="h-5 w-5" />
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = variants[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`${style.bg} ${style.border} ${style.text} border rounded-lg p-4 flex items-start space-x-3`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">{style.icon}</span>
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AlertDescription: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return <div className="text-sm">{children}</div>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
// Fügen Sie diese Funktion direkt in die Datei ein
|
||||||
|
function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
currentTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ currentTitle }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Definiere die feste Struktur der Breadcrumbs
|
||||||
|
const generateBreadcrumbs = () => {
|
||||||
|
const segments = location.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// Basis-Breadcrumb-Struktur
|
||||||
|
const breadcrumbs = [];
|
||||||
|
|
||||||
|
// Portfolio ist immer der erste Level nach Home
|
||||||
|
if (segments.includes('portfolio')) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
title: 'Portfolio',
|
||||||
|
path: '/portfolio',
|
||||||
|
isLast: segments.length === 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn wir in einem Projekt sind, füge den Projekttitel hinzu
|
||||||
|
if (segments.length > 1 && currentTitle) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
title: currentTitle,
|
||||||
|
path: location.pathname,
|
||||||
|
isLast: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = generateBreadcrumbs();
|
||||||
|
|
||||||
|
// Wenn wir nur auf der Homepage sind, zeigen wir keine Breadcrumbs
|
||||||
|
if (location.pathname === '/') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center gap-2 py-4 sm:py-6">
|
||||||
|
{/* Home ist immer der erste Link */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{breadcrumbs.map((crumb) => (
|
||||||
|
<React.Fragment key={crumb.path}>
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-600" />
|
||||||
|
|
||||||
|
{crumb.isLast ? (
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{crumb.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={crumb.path}
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{crumb.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Breadcrumbs;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Button.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
isLoading?: boolean;
|
||||||
|
variant?: 'default' | 'outline' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({
|
||||||
|
className = '',
|
||||||
|
variant = 'default',
|
||||||
|
size = 'md',
|
||||||
|
isLoading,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-primary text-white hover:bg-primary/90',
|
||||||
|
outline: 'border border-primary text-primary hover:bg-primary hover:text-white',
|
||||||
|
ghost: 'text-primary hover:bg-primary/10'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-9 px-4 text-sm',
|
||||||
|
md: 'h-12 px-6 text-base',
|
||||||
|
lg: 'h-14 px-8 text-lg'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={isLoading || disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{leftIcon && <span className="mr-2">{leftIcon}</span>}
|
||||||
|
{children}
|
||||||
|
{rightIcon && <span className="ml-2">{rightIcon}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
// Fügen Sie diese Funktion direkt in die Datei ein
|
||||||
|
function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Phone, Mail, ArrowRight } from "lucide-react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
export function ContactBar() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -50 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
className="bg-gradient-to-r from-cyan-900 to-slate-900 text-white py-2 px-4"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||||
|
<div className="flex items-center space-x-6 text-sm">
|
||||||
|
<a href="tel:+1234567890" className="flex items-center space-x-2 hover:text-cyan-400 transition-colors">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
<span>+1 234 567 890</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:info@damjan-savic.com"
|
||||||
|
className="flex items-center space-x-2 hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<span>info@damjan-savic.com</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Link to="/contact" className="flex items-center space-x-1 text-sm hover:text-cyan-400 transition-colors group">
|
||||||
|
<span>Contact Us</span>
|
||||||
|
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Send, AlertCircle } from 'lucide-react';
|
||||||
|
import { getCsrfToken } from '../utils/csrf';
|
||||||
|
import { rateLimiter } from '../utils/rateLimiting';
|
||||||
|
|
||||||
|
interface ContactFormProps {
|
||||||
|
onSubmit: (data: FormData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContactForm: React.FC<ContactFormProps> = ({ onSubmit }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check rate limiting
|
||||||
|
const clientIp = '127.0.0.1'; // In production, get this from the request
|
||||||
|
if (rateLimiter.isRateLimited(clientIp)) {
|
||||||
|
const timeToReset = Math.ceil(rateLimiter.getTimeToReset(clientIp) / 1000 / 60);
|
||||||
|
throw new Error(`Too many attempts. Please try again in ${timeToReset} minutes.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CSRF token
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('email', email);
|
||||||
|
formData.append('message', message);
|
||||||
|
formData.append('csrf_token', getCsrfToken());
|
||||||
|
|
||||||
|
await onSubmit(formData);
|
||||||
|
setSuccess(true);
|
||||||
|
setName('');
|
||||||
|
setEmail('');
|
||||||
|
setMessage('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg flex items-center">
|
||||||
|
<AlertCircle className="h-5 w-5 mr-2" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-500/10 border border-green-500/20 text-green-500 p-4 rounded-lg">
|
||||||
|
Message sent successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-white/80 mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-white/80 mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-white/80 mb-2">Message</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-gray-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Sending...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Send className="h-5 w-5 mr-2" />
|
||||||
|
Send Message
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactForm;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Phone, Mail, ArrowRight } from "lucide-react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
export function ContactInfo() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg mx-4 mb-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-white tracking-wider uppercase">
|
||||||
|
Kontakt
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="tel:+1234567890"
|
||||||
|
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4 text-zinc-500 group-hover:text-white transition-colors duration-200" />
|
||||||
|
<span>+1 234 567 890</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:info@damjan-savic.com"
|
||||||
|
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4 text-zinc-500 group-hover:text-white transition-colors duration-200" />
|
||||||
|
<span>info@damjan-savic.com</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/contact"
|
||||||
|
className="flex items-center justify-between mt-6 px-4 py-2 bg-zinc-800/50
|
||||||
|
hover:bg-zinc-800 rounded-full text-sm text-zinc-400 hover:text-white
|
||||||
|
transition-all duration-200 group border border-zinc-800 hover:border-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="tracking-wide">Kontakt aufnehmen</span>
|
||||||
|
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import CookieConsent from 'react-cookie-consent';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const CookieBanner = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CookieConsent
|
||||||
|
location="bottom"
|
||||||
|
buttonText={t('common.cookies.accept')}
|
||||||
|
declineButtonText={t('common.cookies.decline')}
|
||||||
|
enableDeclineButton
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.1)'
|
||||||
|
}}
|
||||||
|
buttonStyle={{
|
||||||
|
background: '#ff6b00',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
}}
|
||||||
|
declineButtonStyle={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
}}
|
||||||
|
expires={365}
|
||||||
|
onAccept={() => {
|
||||||
|
// Initialize analytics only after consent
|
||||||
|
window.initializeAnalytics();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.cookies.message')}
|
||||||
|
</CookieConsent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CookieBanner;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-orange-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
|
||||||
|
<p className="text-white/70 mb-6">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
export function FloatingPaths({ position }: { position: number }) {
|
||||||
|
const paths = Array.from({ length: 36 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
|
||||||
|
380 - i * 5 * position
|
||||||
|
} -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
|
||||||
|
152 - i * 5 * position
|
||||||
|
} ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
|
||||||
|
684 - i * 5 * position
|
||||||
|
} ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
|
||||||
|
color: `rgba(var(--accent),${0.1 + i * 0.01})`,
|
||||||
|
width: 0.5 + i * 0.03,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<svg className="w-full h-full text-accent" viewBox="0 0 696 316" fill="none">
|
||||||
|
<title>Background Paths</title>
|
||||||
|
{paths.map((path) => (
|
||||||
|
<motion.path
|
||||||
|
key={path.id}
|
||||||
|
d={path.d}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={path.width}
|
||||||
|
strokeOpacity={0.1 + path.id * 0.01}
|
||||||
|
initial={{ pathLength: 0.3, opacity: 0.6 }}
|
||||||
|
animate={{
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: [0.3, 0.6, 0.3],
|
||||||
|
pathOffset: [0, 1, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 20 + Math.random() * 10,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// components/Footer.tsx
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Mail, Linkedin, Github, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-zinc-900 pt-16 pb-8 px-4 sm:px-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 mb-12">
|
||||||
|
{/* Contact Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-white text-lg font-semibold mb-4">
|
||||||
|
{t('footer.sections.contact.title')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors">
|
||||||
|
<Mail size={18} />
|
||||||
|
<a
|
||||||
|
href={`mailto:${t('footer.sections.contact.email')}`}
|
||||||
|
className="hover:underline"
|
||||||
|
aria-label={t('footer.sections.contact.aria.emailLink')}
|
||||||
|
>
|
||||||
|
{t('footer.sections.contact.email')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 text-zinc-400">
|
||||||
|
<MapPin size={18} />
|
||||||
|
<span aria-label={t('footer.sections.contact.aria.locationText')}>
|
||||||
|
{t('footer.sections.contact.location')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-white text-lg font-semibold mb-4">
|
||||||
|
{t('footer.sections.navigation.title')}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li>
|
||||||
|
<Link to="/portfolio" className="text-zinc-400 hover:text-white transition-colors">
|
||||||
|
{t('footer.sections.navigation.links.portfolio')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/blog" className="text-zinc-400 hover:text-white transition-colors">
|
||||||
|
{t('footer.sections.navigation.links.blog')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/about" className="text-zinc-400 hover:text-white transition-colors">
|
||||||
|
{t('footer.sections.navigation.links.about')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/contact" className="text-zinc-400 hover:text-white transition-colors">
|
||||||
|
{t('footer.sections.navigation.links.contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Media Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-white text-lg font-semibold mb-4">
|
||||||
|
{t('footer.sections.social.title')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/damjan-savić-720288127/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
aria-label={t('footer.sections.social.aria.linkedin')}
|
||||||
|
>
|
||||||
|
<Linkedin size={24} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/damjan1996"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
aria-label={t('footer.sections.social.aria.github')}
|
||||||
|
>
|
||||||
|
<Github size={24} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal Section */}
|
||||||
|
<div className="border-t border-zinc-800 pt-8">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||||
|
<p className="text-zinc-400 text-sm">
|
||||||
|
© {currentYear} Damjan Savić. {t('footer.legal.copyright')}
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<Link to="/privacy" className="text-zinc-400 hover:text-white text-sm transition-colors">
|
||||||
|
{t('footer.legal.links.privacy')}
|
||||||
|
</Link>
|
||||||
|
<Link to="/terms" className="text-zinc-400 hover:text-white text-sm transition-colors">
|
||||||
|
{t('footer.legal.links.terms')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ImageOff } from 'lucide-react';
|
||||||
|
|
||||||
|
declare module 'react' {
|
||||||
|
interface ImgHTMLAttributes<T> extends React.HTMLAttributes<T> {
|
||||||
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageWithFallbackProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
sizes?: string;
|
||||||
|
loading?: 'lazy' | 'eager';
|
||||||
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageWithFallback: React.FC<ImageWithFallbackProps> = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className = '',
|
||||||
|
sizes = '100vw',
|
||||||
|
loading = 'lazy',
|
||||||
|
fetchpriority = 'auto'
|
||||||
|
}) => {
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center bg-gray-900 ${className}`}>
|
||||||
|
<div className="text-center p-4">
|
||||||
|
<ImageOff className="h-8 w-8 mx-auto mb-2 text-gray-500" />
|
||||||
|
<span className="text-sm text-gray-500">{alt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{isLoading && (
|
||||||
|
<div className={`absolute inset-0 bg-gray-900/50 animate-pulse ${className}`} />
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`${className} ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`}
|
||||||
|
loading={loading}
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
|
sizes={sizes}
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageWithFallback;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Input.tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className = '', error, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<input
|
||||||
|
className={`w-full h-12 bg-zinc-900/50 border border-white/10 rounded-lg px-4 text-white
|
||||||
|
placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary transition-all
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed ${error ? 'border-red-500' : ''} ${className}`}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Label.tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Label: React.FC<LabelProps> = ({ children, required, className = '', ...props }) => {
|
||||||
|
return (
|
||||||
|
<label className={`block text-sm font-medium text-white/80 mb-2 ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
{required && <span className="text-red-400 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const LanguageSwitcher: React.FC = () => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'de', name: 'Deutsch' },
|
||||||
|
{ code: 'sr', name: 'Srpski' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentLanguage = languages.find(lang => lang.code === i18n.language)?.name || 'Language';
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="flex items-center space-x-2 text-zinc-400 hover:text-white
|
||||||
|
px-4 py-2 rounded-full transition-colors duration-200
|
||||||
|
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-label="Select language"
|
||||||
|
>
|
||||||
|
<Globe className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="text-sm">{currentLanguage}</span>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute right-0 mt-2 w-48 rounded-lg overflow-hidden
|
||||||
|
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm
|
||||||
|
shadow-lg"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Languages"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<motion.button
|
||||||
|
key={lang.code}
|
||||||
|
whileHover={{ backgroundColor: 'rgba(39, 39, 42, 0.5)' }}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
||||||
|
${i18n.language === lang.code
|
||||||
|
? 'bg-zinc-800 text-white'
|
||||||
|
: 'text-zinc-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
i18n.changeLanguage(lang.code);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i18n.language === lang.code}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageSwitcher;
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// components/Layout.tsx
|
||||||
|
import { useEffect, useState, ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Menu, X, Home, User, Briefcase, BookOpen, Phone, LayoutDashboard } from "lucide-react";
|
||||||
|
import { supabase } from "../utils/supabaseClient";
|
||||||
|
import { ContactInfo } from "../components/ContactInfo";
|
||||||
|
import { NavLink } from "../components/NavLink";
|
||||||
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout = ({ children }: LayoutProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAdmin = async () => {
|
||||||
|
const { data, error } = await supabase.auth.getSession();
|
||||||
|
if (error) {
|
||||||
|
console.error("Error checking admin status:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.session) {
|
||||||
|
const { user } = data.session;
|
||||||
|
const { data: adminData, error: adminError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("isAdmin")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
if (adminError) {
|
||||||
|
console.error("Error checking admin role:", adminError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsAdmin(adminData.isAdmin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAdmin();
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrolled(window.scrollY > 0);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigationLinks = [
|
||||||
|
{ path: "/", label: t("navigation.main.home"), icon: <Home className="h-5 w-5" /> },
|
||||||
|
{ path: "/about", label: t("navigation.main.about"), icon: <User className="h-5 w-5" /> },
|
||||||
|
{ path: "/portfolio", label: t("navigation.main.portfolio"), icon: <Briefcase className="h-5 w-5" /> },
|
||||||
|
{ path: "/blog", label: t("navigation.main.blog"), icon: <BookOpen className="h-5 w-5" /> },
|
||||||
|
{ path: "/contact", label: t("navigation.main.contact"), icon: <Phone className="h-5 w-5" /> },
|
||||||
|
...(isAdmin
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
label: t("navigation.admin.dashboard"),
|
||||||
|
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex flex-col">
|
||||||
|
<nav
|
||||||
|
className={`
|
||||||
|
fixed w-full z-50 transition-all duration-300
|
||||||
|
${scrolled
|
||||||
|
? "bg-zinc-900/80 backdrop-blur supports-[backdrop-filter]:bg-zinc-900/80"
|
||||||
|
: "bg-transparent"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<Link to="/" className="flex items-center space-x-2 group shrink-0">
|
||||||
|
<motion.img
|
||||||
|
src="/logo.png"
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-auto"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
{navigationLinks.map(({ path, label, icon }) => (
|
||||||
|
<NavLink key={path} to={path} icon={icon} label={label} className="text-sm" />
|
||||||
|
))}
|
||||||
|
<div className="ml-4 text-zinc-400 pl-2 border-l border-zinc-700">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
className="md:hidden p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 bg-black/50 md:hidden"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
className="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-zinc-900 shadow-xl md:hidden overflow-y-auto border-l border-zinc-800"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<ContactInfo />
|
||||||
|
<div className="flex-1 py-4 px-4">
|
||||||
|
{navigationLinks.map(({ path, label, icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={path}
|
||||||
|
to={path}
|
||||||
|
icon={icon}
|
||||||
|
label={label}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center w-full mb-1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-zinc-800 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-zinc-500">Sprache ändern</span>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</nav>
|
||||||
|
<main className="flex-1 pt-16">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
|
||||||
|
interface LazyComponentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LazyComponent: React.FC<LazyComponentProps> = ({ children, fallback = <LoadingSpinner /> }) => {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={fallback}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LazyComponent;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingSpinner = ({ size = 'md', className = '' }: LoadingSpinnerProps) => {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-8 h-8',
|
||||||
|
md: 'w-12 h-12',
|
||||||
|
lg: 'w-16 h-16'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center min-h-[200px] ${className}`}>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear"
|
||||||
|
}}
|
||||||
|
className={`relative ${sizes[size]}`}
|
||||||
|
>
|
||||||
|
{/* Base Triangle */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M50 10 L90 80 L10 80 Z"
|
||||||
|
className="fill-none stroke-zinc-800"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Animated Triangle Segments */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d="M50 10 L90 80"
|
||||||
|
className="stroke-white"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{
|
||||||
|
pathLength: [0, 1, 0],
|
||||||
|
opacity: [0.2, 1, 0.2]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M90 80 L10 80"
|
||||||
|
className="stroke-white"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{
|
||||||
|
pathLength: [0, 1, 0],
|
||||||
|
opacity: [0.2, 1, 0.2]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
delay: 0.66
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M10 80 L50 10"
|
||||||
|
className="stroke-white"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{
|
||||||
|
pathLength: [0, 1, 0],
|
||||||
|
opacity: [0.2, 1, 0.2]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
delay: 1.33
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Optional inner gradient effect */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900/50 to-transparent rounded-full" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// src/components/MDXProvider.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { MDXProvider } from '@mdx-js/react';
|
||||||
|
|
||||||
|
const components = {
|
||||||
|
h1: (props: React.ComponentProps<'h1'>) => (
|
||||||
|
<h1 className="text-3xl font-bold my-6" {...props} />
|
||||||
|
),
|
||||||
|
h2: (props: React.ComponentProps<'h2'>) => (
|
||||||
|
<h2 className="text-2xl font-bold my-4" {...props} />
|
||||||
|
),
|
||||||
|
h3: (props: React.ComponentProps<'h3'>) => (
|
||||||
|
<h3 className="text-xl font-bold my-3" {...props} />
|
||||||
|
),
|
||||||
|
p: (props: React.ComponentProps<'p'>) => (
|
||||||
|
<p className="my-4 text-muted-foreground" {...props} />
|
||||||
|
),
|
||||||
|
code: (props: React.ComponentProps<'code'>) => (
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-sm" {...props} />
|
||||||
|
),
|
||||||
|
pre: (props: React.ComponentProps<'pre'>) => (
|
||||||
|
<pre className="bg-muted p-4 rounded-lg my-4 overflow-x-auto" {...props} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MDXLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <MDXProvider components={components}>{children}</MDXProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// components/NavLink.tsx
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface NavLinkProps {
|
||||||
|
to: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavLink({ to, icon, label, onClick, className }: NavLinkProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const isActive = location.pathname === to
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
relative flex items-center gap-2 rounded-full py-2 px-4
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
${isActive ? 'text-white' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm tracking-wide">{label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeIndicator"
|
||||||
|
className="absolute inset-0 rounded-full bg-zinc-800/50 -z-10"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client"
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { motion, AnimatePresence, cubicBezier } from "framer-motion"
|
||||||
|
import { useLocation } from "react-router-dom"
|
||||||
|
|
||||||
|
interface PageTransitionProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const customEase = cubicBezier(0.25, 0.1, 0.25, 1)
|
||||||
|
|
||||||
|
const Logo = () => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.3, ease: customEase },
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 1.2,
|
||||||
|
transition: { duration: 0.3, ease: customEase },
|
||||||
|
}}
|
||||||
|
className="w-16 h-16"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Logo"
|
||||||
|
className="w-full h-full object-contain filter brightness-200"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const PageTransition = ({ children }: PageTransitionProps) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(true)
|
||||||
|
const [isOverlayActive, setIsOverlayActive] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsTransitioning(true)
|
||||||
|
setIsOverlayActive(true)
|
||||||
|
|
||||||
|
const overlayTimer = setTimeout(() => {
|
||||||
|
setIsOverlayActive(false)
|
||||||
|
}, 1200) // Overlay animation duration
|
||||||
|
|
||||||
|
const transitionTimer = setTimeout(() => {
|
||||||
|
setIsTransitioning(false)
|
||||||
|
}, 800)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(overlayTimer)
|
||||||
|
clearTimeout(transitionTimer)
|
||||||
|
}
|
||||||
|
}, [location])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div key={location.pathname} className="relative">
|
||||||
|
{/* Transition Container */}
|
||||||
|
<div className={`fixed inset-0 pointer-events-none ${isOverlayActive ? 'z-50' : '-z-10'}`}>
|
||||||
|
{/* Overlay Animation */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-zinc-900"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: [0, 1, 1, 0],
|
||||||
|
transition: {
|
||||||
|
duration: 1.2,
|
||||||
|
times: [0, 0.3, 0.7, 1],
|
||||||
|
ease: customEase,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isTransitioning && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
delay: 0.3,
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Logo />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
delay: 0.8,
|
||||||
|
ease: customEase,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0, y: -20, transition: { duration: 0.3 } }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageTransition
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NavigationItem {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostNavigationProps {
|
||||||
|
previous?: NavigationItem;
|
||||||
|
next?: NavigationItem;
|
||||||
|
basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostNavigation: React.FC<PostNavigationProps> = ({ previous, next, basePath }) => {
|
||||||
|
if (!previous && !next) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="border-t border-white/10 mt-12 pt-8">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{previous ? (
|
||||||
|
<Link
|
||||||
|
to={`${basePath}/${previous.slug}`}
|
||||||
|
className="group flex items-center text-white/60 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2 transition-transform group-hover:-translate-x-1" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-white/40">Previous</div>
|
||||||
|
<div className="line-clamp-1">{previous.title}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{next ? (
|
||||||
|
<Link
|
||||||
|
to={`${basePath}/${next.slug}`}
|
||||||
|
className="group flex items-center text-right text-white/60 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-white/40">Next</div>
|
||||||
|
<div className="line-clamp-1">{next.title}</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostNavigation
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// components/ProjectContentTemplate.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Calendar, Building2, Clock, Tag } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content?: string;
|
||||||
|
points?: string[];
|
||||||
|
code?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectTranslation {
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
client: string;
|
||||||
|
duration: string;
|
||||||
|
technologies: string[];
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
intro: string;
|
||||||
|
challenge: Section;
|
||||||
|
solution: Section;
|
||||||
|
technical: Section;
|
||||||
|
implementation: Section;
|
||||||
|
results: Section;
|
||||||
|
conclusion: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectContentTemplateProps {
|
||||||
|
translationKey: string;
|
||||||
|
fallbackData?: ProjectTranslation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectContentTemplate: React.FC<ProjectContentTemplateProps> = ({
|
||||||
|
translationKey,
|
||||||
|
fallbackData
|
||||||
|
}) => {
|
||||||
|
// Nur 't' aus dem Hook extrahieren, da 'i18n' nicht verwendet wird
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Übersetzten Inhalt abrufen
|
||||||
|
const translatedContent = t(`${translationKey}`, {
|
||||||
|
returnObjects: true,
|
||||||
|
defaultValue: fallbackData
|
||||||
|
}) as ProjectTranslation;
|
||||||
|
|
||||||
|
const renderSection = (section: Section) => {
|
||||||
|
if (!section) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
{section.description && (
|
||||||
|
<p className="text-zinc-400 mb-6">
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{section.content && (
|
||||||
|
<div className="prose prose-invert max-w-none mb-6">
|
||||||
|
{section.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{section.points && (
|
||||||
|
<ul className="list-disc list-inside text-zinc-300 space-y-2">
|
||||||
|
{section.points.map((point, index) => (
|
||||||
|
<li key={index} className="ml-4">
|
||||||
|
{point}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{section.code && (
|
||||||
|
<pre className="bg-zinc-800/50 p-4 rounded-lg overflow-x-auto border border-zinc-800">
|
||||||
|
<code className="text-zinc-300">
|
||||||
|
{section.code}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{section.image && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<img
|
||||||
|
src={section.image}
|
||||||
|
alt={section.title}
|
||||||
|
className="rounded-lg w-full"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="max-w-4xl mx-auto">
|
||||||
|
{/* Project Meta Information */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-6 text-zinc-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<time>{translatedContent.meta.date}</time>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
<span>{translatedContent.meta.client}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{translatedContent.meta.duration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||||
|
{translatedContent.meta.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-zinc-400">
|
||||||
|
{translatedContent.meta.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Introduction */}
|
||||||
|
<div className="prose prose-invert max-w-none mb-12">
|
||||||
|
<p>{translatedContent.content.intro}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Sections */}
|
||||||
|
{renderSection(translatedContent.content.challenge)}
|
||||||
|
{renderSection(translatedContent.content.solution)}
|
||||||
|
{renderSection(translatedContent.content.technical)}
|
||||||
|
{renderSection(translatedContent.content.implementation)}
|
||||||
|
{renderSection(translatedContent.content.results)}
|
||||||
|
|
||||||
|
{/* Conclusion */}
|
||||||
|
{translatedContent.content.conclusion && (
|
||||||
|
<div className="prose prose-invert max-w-none mb-12">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">
|
||||||
|
{t('portfolio.sections.conclusion')}
|
||||||
|
</h2>
|
||||||
|
<p>{translatedContent.content.conclusion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technologies */}
|
||||||
|
<div className="mt-12 pt-6 border-t border-zinc-800">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Tag className="h-4 w-4 text-zinc-400" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{translatedContent.meta.technologies.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-3 py-1 bg-zinc-800/50 border border-zinc-800 rounded-full text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
{t(`technologies.${tech}`, tech)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectContentTemplate;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
interface ResponsiveImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
sizes?: string;
|
||||||
|
loading?: 'lazy' | 'eager';
|
||||||
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResponsiveImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className = '',
|
||||||
|
sizes = '100vw',
|
||||||
|
loading = 'lazy',
|
||||||
|
fetchpriority = 'auto'
|
||||||
|
}: ResponsiveImageProps) {
|
||||||
|
// Generate Unsplash responsive URLs
|
||||||
|
const generateSrcSet = (url: string) => {
|
||||||
|
if (!url.includes('unsplash.com')) return undefined;
|
||||||
|
|
||||||
|
const widths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
|
||||||
|
return widths
|
||||||
|
.map((w) => {
|
||||||
|
const modifiedUrl = url.replace(/w=\d+/, `w=${w}`);
|
||||||
|
return `${modifiedUrl} ${w}w`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const srcSet = generateSrcSet(src);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
loading={loading}
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
|
sizes={sizes}
|
||||||
|
srcSet={srcSet}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom';
|
||||||
|
import { AlertTriangle, Home, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
const RouteErrorBoundary = () => {
|
||||||
|
const error = useRouteError();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
let title = 'Something went wrong';
|
||||||
|
let message = 'An unexpected error occurred';
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
title = 'Page not found';
|
||||||
|
message = "The page you're looking for doesn't exist or has been moved.";
|
||||||
|
} else {
|
||||||
|
title = `Error ${error.status}`;
|
||||||
|
message = error.statusText || 'An error occurred while processing your request.';
|
||||||
|
}
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
message = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-black">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-orange-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold mb-4">{title}</h1>
|
||||||
|
<p className="text-white/70 mb-8">{message}</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-black"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="inline-flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-black"
|
||||||
|
>
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Home Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RouteErrorBoundary;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface SEOProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
article?: boolean;
|
||||||
|
schema?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEO: React.FC<SEOProps> = ({
|
||||||
|
title,
|
||||||
|
description = 'JTL Integration Expert and Digital Commerce Specialist with extensive experience in e-commerce solutions and warehouse management systems.',
|
||||||
|
image = 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||||
|
article = false,
|
||||||
|
schema,
|
||||||
|
}) => {
|
||||||
|
// Nur i18n wird benötigt – t wurde entfernt, da es nicht genutzt wird.
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const siteTitle = 'Damjan Savić';
|
||||||
|
const fullTitle = title ? `${title} | ${siteTitle}` : siteTitle;
|
||||||
|
const siteUrl = 'https://damjan-savic.com';
|
||||||
|
const currentUrl = typeof window !== 'undefined' ? window.location.href : siteUrl;
|
||||||
|
const currentLanguage = i18n.language;
|
||||||
|
|
||||||
|
// Default schema für die Website
|
||||||
|
const defaultSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: 'Damjan Savić',
|
||||||
|
url: siteUrl,
|
||||||
|
image: image,
|
||||||
|
description: description,
|
||||||
|
sameAs: [
|
||||||
|
'https://linkedin.com/in/damjansavic',
|
||||||
|
'https://github.com/damjansavic'
|
||||||
|
],
|
||||||
|
jobTitle: 'JTL Integration Expert',
|
||||||
|
worksFor: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'Independent Consultant'
|
||||||
|
},
|
||||||
|
knowsAbout: [
|
||||||
|
'JTL-Wawi',
|
||||||
|
'E-commerce',
|
||||||
|
'Warehouse Management Systems',
|
||||||
|
'Digital Commerce',
|
||||||
|
'System Integration'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sicherstellen, dass supportedLngs ein Array ist, bevor .filter verwendet wird
|
||||||
|
const alternateUrls: Array<{ hrefLang: string; href: string }> = Array.isArray(i18n.options.supportedLngs)
|
||||||
|
? i18n.options.supportedLngs
|
||||||
|
.filter((lng: string) => lng !== 'cimode')
|
||||||
|
.map((lng: string) => ({
|
||||||
|
hrefLang: lng,
|
||||||
|
href: `${siteUrl}/${lng}${window.location.pathname}`,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
{/* Basic meta tags */}
|
||||||
|
<html lang={currentLanguage} />
|
||||||
|
<title>{fullTitle}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="image" content={image} />
|
||||||
|
<link rel="canonical" href={currentUrl} />
|
||||||
|
|
||||||
|
{/* Language alternates */}
|
||||||
|
{alternateUrls?.map(
|
||||||
|
({ hrefLang, href }: { hrefLang: string; href: string }) => (
|
||||||
|
<link key={hrefLang} rel="alternate" hrefLang={hrefLang} href={href} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<link rel="alternate" hrefLang="x-default" href={siteUrl} />
|
||||||
|
|
||||||
|
{/* Open Graph meta tags */}
|
||||||
|
<meta property="og:url" content={currentUrl} />
|
||||||
|
<meta property="og:title" content={fullTitle} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
<meta property="og:type" content={article ? 'article' : 'website'} />
|
||||||
|
<meta property="og:site_name" content={siteTitle} />
|
||||||
|
<meta property="og:locale" content={currentLanguage} />
|
||||||
|
{alternateUrls?.map(
|
||||||
|
({ hrefLang }: { hrefLang: string; href: string }) => (
|
||||||
|
<meta key={hrefLang} property="og:locale:alternate" content={hrefLang} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Twitter Card meta tags */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={fullTitle} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={image} />
|
||||||
|
|
||||||
|
{/* Additional meta tags */}
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="JTL, E-commerce, Warehouse Management, Digital Commerce, System Integration, JTL-Wawi, WMS"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="Damjan Savić" />
|
||||||
|
|
||||||
|
{/* Schema.org markup */}
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(schema || defaultSchema)}
|
||||||
|
</script>
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SEO;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { motion, useAnimation } from "framer-motion"
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
interface ScrollAnimationProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrollAnimation: React.FC<ScrollAnimationProps> = ({ children, delay = 0 }) => {
|
||||||
|
const controls = useAnimation()
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
controls.start({ opacity: 1, y: 0, transition: { duration: 0.5, delay: delay } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0.5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
observer.observe(ref.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (ref.current) {
|
||||||
|
observer.unobserve(ref.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [controls, delay])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div ref={ref} initial={{ opacity: 0, y: 20 }} animate={controls}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScrollAnimation
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Share2, X, Twitter, Facebook, Linkedin, Link as LinkIcon } from 'lucide-react';
|
||||||
|
import { useOnClickOutside } from '../hooks/useOnClickOutside';
|
||||||
|
|
||||||
|
interface ShareMenuProps {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShareMenu: React.FC<ShareMenuProps> = ({ title, url, description }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle');
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useOnClickOutside(menuRef, () => setIsOpen(false));
|
||||||
|
|
||||||
|
const shareLinks = [
|
||||||
|
{
|
||||||
|
name: 'Twitter',
|
||||||
|
icon: Twitter,
|
||||||
|
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`,
|
||||||
|
label: 'Share on Twitter'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Facebook',
|
||||||
|
icon: Facebook,
|
||||||
|
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||||
|
label: 'Share on Facebook'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LinkedIn',
|
||||||
|
icon: Linkedin,
|
||||||
|
href: `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&summary=${encodeURIComponent(description)}`,
|
||||||
|
label: 'Share on LinkedIn'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopyStatus('copied');
|
||||||
|
setTimeout(() => setCopyStatus('idle'), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-black transition-colors p-2 rounded-full hover:bg-white/10"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Share this content"
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-5 w-5" aria-hidden="true" /> : <Share2 className="h-5 w-5" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-2 w-48 rounded-lg bg-gray-900 shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||||
|
role="menu"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{shareLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-orange-500"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={link.label}
|
||||||
|
>
|
||||||
|
<link.icon className="h-4 w-4 mr-3" aria-hidden="true" />
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-orange-500"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LinkIcon className="h-4 w-4 mr-3" aria-hidden="true" />
|
||||||
|
{copyStatus === 'copied' ? 'Copied!' : 'Copy Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareMenu
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Skeleton: React.FC<SkeletonProps> = ({ className }) => (
|
||||||
|
<div className={`animate-pulse bg-gray-700/50 rounded ${className}`}></div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BlogPostSkeleton = () => (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||||
|
<Skeleton className="h-8 w-3/4 mb-4" />
|
||||||
|
<Skeleton className="h-4 w-1/4 mb-8" />
|
||||||
|
<Skeleton className="h-[400px] w-full mb-8 rounded-xl" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-4 w-4/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ProjectSkeleton = () => (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||||
|
<Skeleton className="h-8 w-2/3 mb-4" />
|
||||||
|
<div className="flex gap-4 mb-8">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-[400px] w-full mb-8 rounded-xl" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-4 w-4/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PortfolioSkeleton = () => (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||||
|
<Skeleton className="h-8 w-48 mx-auto mb-12" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="aspect-[4/3]">
|
||||||
|
<Skeleton className="w-full h-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsMatrixProps {
|
||||||
|
skills: Skill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkillsMatrix: React.FC<SkillsMatrixProps> = ({ skills }) => {
|
||||||
|
const [ref, inView] = useInView({
|
||||||
|
triggerOnce: true,
|
||||||
|
threshold: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = Array.from(new Set(skills.map(skill => skill.category)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{categories.map((category, categoryIndex) => (
|
||||||
|
<div key={category} className="bg-gray-900/50 p-6 rounded-xl">
|
||||||
|
<h3 className="text-xl font-semibold mb-6">{category}</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{skills
|
||||||
|
.filter(skill => skill.category === category)
|
||||||
|
.map((skill, skillIndex) => (
|
||||||
|
<div key={skill.name}>
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<div className="group relative">
|
||||||
|
<span>{skill.name}</span>
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 w-64 p-4 bg-gray-800 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||||
|
<p className="text-sm text-white/80">{skill.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>{skill.level}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-orange-500"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={inView ? { width: `${skill.level}%` } : { width: 0 }}
|
||||||
|
transition={{ duration: 1, delay: categoryIndex * 0.2 + skillIndex * 0.1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SkillsMatrix;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const SkipToContent: React.FC = () => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className={`
|
||||||
|
fixed top-4 left-4 px-4 py-2 bg-orange-500 text-white rounded-lg
|
||||||
|
transform transition-transform duration-200
|
||||||
|
${isFocused ? 'translate-y-0' : '-translate-y-full'}
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2
|
||||||
|
`}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SkipToContent
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Star, Quote } from 'lucide-react';
|
||||||
|
import ImageWithFallback from './ImageWithFallback';
|
||||||
|
|
||||||
|
interface TestimonialProps {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
company: string;
|
||||||
|
image: string;
|
||||||
|
content: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestimonialCard: React.FC<TestimonialProps> = ({
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
company,
|
||||||
|
image,
|
||||||
|
content,
|
||||||
|
rating
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/50 p-6 rounded-xl relative">
|
||||||
|
<Quote className="absolute top-4 right-4 h-8 w-8 text-orange-500/20" />
|
||||||
|
<div className="flex items-center space-x-4 mb-4">
|
||||||
|
<ImageWithFallback
|
||||||
|
src={image}
|
||||||
|
alt={name}
|
||||||
|
className="w-12 h-12 rounded-full"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{name}</h3>
|
||||||
|
<p className="text-sm text-white/60">{role} at {company}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`h-4 w-4 ${i < rating ? 'text-orange-500' : 'text-gray-600'}`}
|
||||||
|
fill={i < rating ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-white/80">{content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestimonialCard;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className = '', error, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<textarea
|
||||||
|
className={`w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3
|
||||||
|
text-white placeholder-white/40 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary transition-all resize-none
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${error ? 'border-red-500' : ''}
|
||||||
|
${className}`}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export default Textarea;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { handleError } from '../utils/errorHandling';
|
||||||
|
|
||||||
|
interface AsyncState<T> {
|
||||||
|
data: T | null;
|
||||||
|
error: Error | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsync<T>() {
|
||||||
|
const [state, setState] = useState<AsyncState<T>>({
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const execute = useCallback(async (asyncFunction: () => Promise<T>) => {
|
||||||
|
setState({ data: null, error: null, loading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await asyncFunction();
|
||||||
|
setState({ data, error: null, loading: false });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
const handledError = handleError(error, 'Async Operation');
|
||||||
|
setState({ data: null, error: handledError, loading: false });
|
||||||
|
throw handledError;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, execute };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { RefObject, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useOnClickOutside(ref: RefObject<HTMLElement>, handler: (event: MouseEvent | TouchEvent) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handler(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', listener);
|
||||||
|
document.addEventListener('touchstart', listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', listener);
|
||||||
|
document.removeEventListener('touchstart', listener);
|
||||||
|
};
|
||||||
|
}, [ref, handler]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// src/hooks/useProjectData.ts
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useProjectData = <T = unknown>(projectId: string): T | null => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [projectData, setProjectData] = useState<T | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjectData = async () => {
|
||||||
|
try {
|
||||||
|
const projectModule = await import(
|
||||||
|
`../i18n/locales/${i18n.language}/portfolio/projects/${projectId}`
|
||||||
|
);
|
||||||
|
setProjectData(projectModule[projectId] as T);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load project data for ${projectId}:`, error);
|
||||||
|
setProjectData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjectData();
|
||||||
|
}, [projectId, i18n.language]);
|
||||||
|
|
||||||
|
return projectData;
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// src/i18n/index.ts
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
import translationDE from './locales/de';
|
||||||
|
import translationEN from './locales/en';
|
||||||
|
import translationSR from './locales/sr';
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
de: {
|
||||||
|
translation: translationDE
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
translation: translationEN
|
||||||
|
},
|
||||||
|
sr: {
|
||||||
|
translation: translationSR
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fallbackLng: 'en',
|
||||||
|
supportedLngs: ['en', 'de', 'sr'],
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// src/i18n/locales/de/blog/index.ts
|
||||||
|
export const blog = {
|
||||||
|
meta: {
|
||||||
|
title: 'Blog - JTL Integration & Digital Solutions',
|
||||||
|
description: 'Insights und Best Practices zu JTL-WaWi, E-Commerce und digitaler Transformation',
|
||||||
|
header: {
|
||||||
|
title: 'Insights & Best Practices',
|
||||||
|
subtitle: 'Insights und Best Practices zu JTL-WaWi, E-Commerce und digitaler Transformation'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
posts: {
|
||||||
|
'erp-integration-breuninger': {
|
||||||
|
title: 'ApparelMagic und TradeByte: Analyse komplexer Integrationen',
|
||||||
|
date: '2024-02-09',
|
||||||
|
excerpt: 'Detaillierte Analyse einer E-Commerce-Integration zwischen Apparel Magic und Breuninger via TradeByte',
|
||||||
|
category: 'System Integration',
|
||||||
|
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integration', 'Python', 'MariaDB'],
|
||||||
|
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||||
|
content: {
|
||||||
|
intro: {
|
||||||
|
title: 'Apparel Magic meets Breuninger: Eine technische Analyse der TradeByte-Integration',
|
||||||
|
description: 'Die Integration von E-Commerce-Systemen mit etablierten Marktplätzen stellt Unternehmen vor besondere Herausforderungen. In diesem Artikel teile ich meine Erfahrungen aus einem komplexen Integrationsprojekt zwischen Apparel Magic als ERP-System und der Breuninger-Plattform über TradeByte.'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
title: 'Projekthintergrund',
|
||||||
|
systems: {
|
||||||
|
erp: 'Apparel Magic als ERP-System für Produktdaten und Bestandsmanagement',
|
||||||
|
middleware: 'TradeByte als Middleware für die Breuninger-Plattform'
|
||||||
|
},
|
||||||
|
challenges: {
|
||||||
|
sync: 'Bidirektionale Synchronisation von Bestellungen',
|
||||||
|
customers: 'Automatisierte Kundenanlage',
|
||||||
|
delivery: 'Verwaltung von Lieferscheinen',
|
||||||
|
inventory: 'Bestandsmanagement in Echtzeit'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tech: {
|
||||||
|
title: 'Technische Architektur',
|
||||||
|
components: {
|
||||||
|
title: 'Zentrale Komponenten'
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
title: 'Middleware-Datenbank',
|
||||||
|
description: 'Die MariaDB-Datenbank fungiert als zentraler Datenspeicher für die Integration:'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
title: 'API-Integrationen',
|
||||||
|
apparel_magic: {
|
||||||
|
title: 'Apparel Magic API',
|
||||||
|
description: 'Die REST-API von Apparel Magic wird für Kundenanlage und Bestandsmanagement verwendet:'
|
||||||
|
},
|
||||||
|
tradebyte: {
|
||||||
|
title: 'TradeByte Integration',
|
||||||
|
description: 'Die TradeByte-API verwendet eine Kombination aus REST und XML:'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order_process: {
|
||||||
|
title: 'Bestellprozess',
|
||||||
|
steps: {
|
||||||
|
fetch: 'Regelmäßiger Abruf neuer Bestellungen von TradeByte',
|
||||||
|
customers: 'Automatische Kundenanlage in Apparel Magic',
|
||||||
|
process: 'Bestellverarbeitung und Statusaktualisierung',
|
||||||
|
delivery: 'Generierung und Speicherung von Lieferscheinen'
|
||||||
|
},
|
||||||
|
delivery_notes: {
|
||||||
|
title: 'Lieferschein-Management'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
challenges: {
|
||||||
|
title: 'Herausforderungen und Lösungen',
|
||||||
|
status: {
|
||||||
|
title: '1. Status-Management',
|
||||||
|
description: 'Eine besondere Herausforderung war das Management von Bestellstatus:'
|
||||||
|
},
|
||||||
|
error_handling: {
|
||||||
|
title: '2. Fehlerbehandlung und Monitoring',
|
||||||
|
description: 'Robuste Fehlerbehandlung war essentiell für den Produktivbetrieb:'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
best_practices: {
|
||||||
|
title: 'Best Practices',
|
||||||
|
api: {
|
||||||
|
title: '1. API-Kommunikation',
|
||||||
|
items: {
|
||||||
|
retry: 'Implementierung von Retry-Mechanismen',
|
||||||
|
errors: 'Sorgfältiges Error Handling',
|
||||||
|
logging: 'Ausführliches Logging aller Kommunikation'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: '2. Datenmanagement',
|
||||||
|
items: {
|
||||||
|
storage: 'Zentrale Datenhaltung in MariaDB',
|
||||||
|
transactions: 'Transaktionssicherheit bei kritischen Operationen',
|
||||||
|
validation: 'Regelmäßige Datenvalidierung'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
automation: {
|
||||||
|
title: '3. Prozessautomatisierung',
|
||||||
|
items: {
|
||||||
|
customers: 'Automatisierte Kundenanlage',
|
||||||
|
status: 'Automatische Statusaktualisierungen',
|
||||||
|
delivery: 'Systematisches Lieferschein-Management'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lessons: {
|
||||||
|
title: 'Lessons Learned',
|
||||||
|
api: {
|
||||||
|
title: '1. API-Design',
|
||||||
|
items: {
|
||||||
|
docs: 'Gründliche API-Dokumentation ist essentiell',
|
||||||
|
auth: 'Verständnis der verschiedenen Authentifizierungsmethoden',
|
||||||
|
limits: 'Berücksichtigung von Rate Limits'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
title: '2. Datenvalidierung',
|
||||||
|
items: {
|
||||||
|
rules: 'Implementierung strenger Validierungsregeln',
|
||||||
|
edge_cases: 'Sorgfältige Handhabung von Sonderfällen',
|
||||||
|
cleanup: 'Automatische Datenbereinigung'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
title: '3. Prozessoptimierung',
|
||||||
|
items: {
|
||||||
|
automation: 'Kontinuierliche Verbesserung der Automatisierung',
|
||||||
|
performance: 'Regelmäßige Performance-Überprüfungen',
|
||||||
|
monitoring: 'Proaktives Monitoring'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
conclusion: {
|
||||||
|
title: 'Fazit',
|
||||||
|
requirements: {
|
||||||
|
understanding: 'Tiefes Verständnis beider Systeme',
|
||||||
|
implementation: 'Sorgfältige Implementierung der API-Kommunikation',
|
||||||
|
error_handling: 'Robuste Fehlerbehandlung',
|
||||||
|
monitoring: 'Kontinuierliches Monitoring'
|
||||||
|
},
|
||||||
|
results: 'Die entwickelte Lösung verarbeitet erfolgreich Bestellungen und ermöglicht eine nahtlose Integration zwischen den Systemen. Besonders die automatisierte Kundenanlage und das Lieferschein-Management haben sich als effizienzsteigernd erwiesen.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
readMore: 'Weiterlesen',
|
||||||
|
loading: {
|
||||||
|
posts: 'Blogbeiträge werden geladen...',
|
||||||
|
post: 'Blogbeitrag wird geladen...'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
posts: 'Fehler beim Laden der Blogbeiträge',
|
||||||
|
post: 'Der gesuchte Blogbeitrag existiert nicht oder wurde verschoben.'
|
||||||
|
},
|
||||||
|
backToBlog: 'Zurück zum Blog',
|
||||||
|
publishedOn: 'Veröffentlicht am',
|
||||||
|
category: 'Kategorie',
|
||||||
|
tags: 'Tags',
|
||||||
|
notFound: {
|
||||||
|
title: 'Beitrag nicht gefunden',
|
||||||
|
message: 'Der gesuchte Blogbeitrag existiert nicht oder wurde verschoben.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
'Integration': 'Integration',
|
||||||
|
'Entwicklung': 'Entwicklung',
|
||||||
|
'Automatisierung': 'Automatisierung'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BlogConfig = typeof blog;
|
||||||
|
export type BlogPost = BlogConfig['posts'][keyof BlogConfig['posts']];
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// src/i18n/locales/de/blog/posts/erp-integration-breuninger.ts
|
||||||
|
export const erpIntegrationBreuninger = {
|
||||||
|
posts: {
|
||||||
|
erp_integration: {
|
||||||
|
title: 'ApparelMagic und TradeByte: Analyse komplexer Integrationen',
|
||||||
|
date: '2024-02-09',
|
||||||
|
excerpt: 'Detaillierte Analyse einer E-Commerce-Integration zwischen Apparel Magic und Breuninger via TradeByte',
|
||||||
|
category: 'System Integration',
|
||||||
|
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integration', 'Python', 'MariaDB'],
|
||||||
|
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||||
|
intro: {
|
||||||
|
title: 'Apparel Magic meets Breuninger: Eine technische Analyse der TradeByte-Integration',
|
||||||
|
description: 'Die Integration von E-Commerce-Systemen mit etablierten Marktplätzen stellt Unternehmen vor besondere Herausforderungen. In diesem Artikel teile ich meine Erfahrungen aus einem komplexen Integrationsprojekt zwischen Apparel Magic als ERP-System und der Breuninger-Plattform über TradeByte.'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
title: 'Projekthintergrund',
|
||||||
|
systems: {
|
||||||
|
erp: 'Apparel Magic als ERP-System für Produktdaten und Bestandsmanagement',
|
||||||
|
middleware: 'TradeByte als Middleware für die Breuninger-Plattform'
|
||||||
|
},
|
||||||
|
challenges: {
|
||||||
|
sync: 'Bidirektionale Synchronisation von Bestellungen',
|
||||||
|
customers: 'Automatisierte Kundenanlage',
|
||||||
|
delivery: 'Verwaltung von Lieferscheinen',
|
||||||
|
inventory: 'Bestandsmanagement in Echtzeit'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tech: {
|
||||||
|
title: 'Technische Architektur',
|
||||||
|
components: {
|
||||||
|
title: 'Zentrale Komponenten'
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
title: 'Middleware-Datenbank',
|
||||||
|
description: 'Die MariaDB-Datenbank fungiert als zentraler Datenspeicher für die Integration:'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
title: 'API-Integrationen',
|
||||||
|
apparel_magic: {
|
||||||
|
title: 'Apparel Magic API',
|
||||||
|
description: 'Die REST-API von Apparel Magic wird für Kundenanlage und Bestandsmanagement verwendet:'
|
||||||
|
},
|
||||||
|
tradebyte: {
|
||||||
|
title: 'TradeByte Integration',
|
||||||
|
description: 'Die TradeByte-API verwendet eine Kombination aus REST und XML:'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order_process: {
|
||||||
|
title: 'Bestellprozess',
|
||||||
|
steps: {
|
||||||
|
fetch: 'Regelmäßiger Abruf neuer Bestellungen von TradeByte',
|
||||||
|
customers: 'Automatische Kundenanlage in Apparel Magic',
|
||||||
|
process: 'Bestellverarbeitung und Statusaktualisierung',
|
||||||
|
delivery: 'Generierung und Speicherung von Lieferscheinen'
|
||||||
|
},
|
||||||
|
delivery_notes: {
|
||||||
|
title: 'Lieferschein-Management'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
challenges: {
|
||||||
|
title: 'Herausforderungen und Lösungen',
|
||||||
|
status: {
|
||||||
|
title: '1. Status-Management',
|
||||||
|
description: 'Eine besondere Herausforderung war das Management von Bestellstatus:'
|
||||||
|
},
|
||||||
|
error_handling: {
|
||||||
|
title: '2. Fehlerbehandlung und Monitoring',
|
||||||
|
description: 'Robuste Fehlerbehandlung war essentiell für den Produktivbetrieb:'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
best_practices: {
|
||||||
|
title: 'Best Practices',
|
||||||
|
api: {
|
||||||
|
title: '1. API-Kommunikation',
|
||||||
|
items: {
|
||||||
|
retry: 'Implementierung von Retry-Mechanismen',
|
||||||
|
errors: 'Sorgfältiges Error Handling',
|
||||||
|
logging: 'Ausführliches Logging aller Kommunikation'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: '2. Datenmanagement',
|
||||||
|
items: {
|
||||||
|
storage: 'Zentrale Datenhaltung in MariaDB',
|
||||||
|
transactions: 'Transaktionssicherheit bei kritischen Operationen',
|
||||||
|
validation: 'Regelmäßige Datenvalidierung'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
automation: {
|
||||||
|
title: '3. Prozessautomatisierung',
|
||||||
|
items: {
|
||||||
|
customers: 'Automatisierte Kundenanlage',
|
||||||
|
status: 'Automatische Statusaktualisierungen',
|
||||||
|
delivery: 'Systematisches Lieferschein-Management'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lessons: {
|
||||||
|
title: 'Lessons Learned',
|
||||||
|
api: {
|
||||||
|
title: '1. API-Design',
|
||||||
|
items: {
|
||||||
|
docs: 'Gründliche API-Dokumentation ist essentiell',
|
||||||
|
auth: 'Verständnis der verschiedenen Authentifizierungsmethoden',
|
||||||
|
limits: 'Berücksichtigung von Rate Limits'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
title: '2. Datenvalidierung',
|
||||||
|
items: {
|
||||||
|
rules: 'Implementierung strenger Validierungsregeln',
|
||||||
|
edge_cases: 'Sorgfältige Handhabung von Sonderfällen',
|
||||||
|
cleanup: 'Automatische Datenbereinigung'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
title: '3. Prozessoptimierung',
|
||||||
|
items: {
|
||||||
|
automation: 'Kontinuierliche Verbesserung der Automatisierung',
|
||||||
|
performance: 'Regelmäßige Performance-Überprüfungen',
|
||||||
|
monitoring: 'Proaktives Monitoring'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
conclusion: {
|
||||||
|
title: 'Fazit',
|
||||||
|
requirements: {
|
||||||
|
understanding: 'Tiefes Verständnis beider Systeme',
|
||||||
|
implementation: 'Sorgfältige Implementierung der API-Kommunikation',
|
||||||
|
error_handling: 'Robuste Fehlerbehandlung',
|
||||||
|
monitoring: 'Kontinuierliches Monitoring'
|
||||||
|
},
|
||||||
|
results: 'Die entwickelte Lösung verarbeitet erfolgreich Bestellungen und ermöglicht eine nahtlose Integration zwischen den Systemen. Besonders die automatisierte Kundenanlage und das Lieferschein-Management haben sich als effizienzsteigernd erwiesen.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// src/i18n/locales/de/blog/posts/fullstack-development-timetracking.ts
|
||||||
|
export const fullstackDevelopmentTimetracking = {
|
||||||
|
meta: {
|
||||||
|
title: 'Fullstack-Entwicklung mit Python und React: Architektur unserer Zeiterfassungslösung',
|
||||||
|
date: '2024-02-09',
|
||||||
|
excerpt: 'Eine technische Deep-Dive in die Implementierung einer modernen Zeiterfassungslösung',
|
||||||
|
category: 'System Architecture',
|
||||||
|
coverImage: '/images/posts/fullstack-development-timetracking/cover.jpg',
|
||||||
|
tags: ['Python', 'React', 'TypeScript', 'MSSQL', 'System Design']
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
intro: {
|
||||||
|
title: 'Fullstack-Entwicklung mit Python und React: Architektur unserer Zeiterfassungslösung',
|
||||||
|
description: 'Die Entwicklung einer robusten Zeiterfassungslösung erfordert nicht nur technisches Know-how, sondern auch ein tiefes Verständnis für komplexe Geschäftsregeln und Benutzeranforderungen. In diesem Artikel teile ich unsere Erfahrungen bei der Implementierung einer modernen Fullstack-Zeiterfassungslösung mit Python und React.'
|
||||||
|
},
|
||||||
|
systemArchitecture: {
|
||||||
|
title: 'Systemarchitektur',
|
||||||
|
frontend: {
|
||||||
|
title: 'Frontend (Next.js + TypeScript)',
|
||||||
|
description: 'Die Frontend-Architektur basiert auf Next.js mit TypeScript und folgt einem komponenten-basierten Ansatz:',
|
||||||
|
code: {
|
||||||
|
types: `// types/TimeEntry.ts
|
||||||
|
interface TimeEntry {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string | null;
|
||||||
|
userId: number;
|
||||||
|
status: 'complete' | 'incomplete';
|
||||||
|
}`,
|
||||||
|
component: `// components/TimeEntryForm.tsx
|
||||||
|
const TimeEntryForm: React.FC<TimeEntryFormProps> = ({ onSubmit }) => {
|
||||||
|
const [entry, setEntry] = useState<TimeEntry>({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
checkIn: new Date().toLocaleTimeString(),
|
||||||
|
checkOut: null,
|
||||||
|
status: 'incomplete'
|
||||||
|
});
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validateTimeEntry(entry)) return;
|
||||||
|
try {
|
||||||
|
await onSubmit(entry);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting time entry:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<DateInput
|
||||||
|
value={entry.date}
|
||||||
|
onChange={(date) => setEntry({ ...entry, date })}
|
||||||
|
/>
|
||||||
|
<TimeInput
|
||||||
|
value={entry.checkIn}
|
||||||
|
onChange={(time) => setEntry({ ...entry, checkIn: time })}
|
||||||
|
/>
|
||||||
|
{/* Additional form elements */}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
title: 'Backend (Flask + MSSQL)',
|
||||||
|
description: 'Das Backend verwendet Flask für die API und MSSQL für die Datenpersistenz:',
|
||||||
|
code: {
|
||||||
|
models: `# models/time_entry.py
|
||||||
|
class TimeEntry(db.Model):
|
||||||
|
__tablename__ = 'Stundenzettel'
|
||||||
|
id = db.Column('ID', db.Decimal, primary_key=True)
|
||||||
|
personal_id = db.Column('Personal_ID', db.Decimal)
|
||||||
|
datum = db.Column('Datum', db.Date)
|
||||||
|
kommen = db.Column('Kommen', db.Time)
|
||||||
|
gehen = db.Column('Gehen', db.Time)
|
||||||
|
@validates('gehen')
|
||||||
|
def validate_checkout(self, key, value):
|
||||||
|
if value and value < self.kommen:
|
||||||
|
raise ValueError('Checkout time cannot be before checkin time')
|
||||||
|
return value`,
|
||||||
|
routes: `# routes/time_entries.py
|
||||||
|
@app.route('/api/time-entries', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def create_time_entry():
|
||||||
|
data = request.get_json()
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
try:
|
||||||
|
validate_time_entry_creation(data, user_id)
|
||||||
|
entry = TimeEntry(
|
||||||
|
personal_id=user_id,
|
||||||
|
datum=data['date'],
|
||||||
|
kommen=data['checkIn'],
|
||||||
|
gehen=data.get('checkOut')
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(entry.to_dict()), 201
|
||||||
|
except ValidationError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
businessLogic: {
|
||||||
|
title: 'Geschäftslogik-Implementierung',
|
||||||
|
validation: {
|
||||||
|
title: 'Validierung von Zeiteinträgen',
|
||||||
|
description: 'Die Validierungslogik stellt sicher, dass alle Geschäftsregeln eingehalten werden:',
|
||||||
|
code: `def validate_time_entry_creation(data: dict, user_id: int) -> None:
|
||||||
|
'''Validates a new time entry according to business rules.'''
|
||||||
|
# Check for existing incomplete entries
|
||||||
|
incomplete_entry = TimeEntry.query.filter_by(
|
||||||
|
personal_id=user_id,
|
||||||
|
gehen=None,
|
||||||
|
datum=data['date']
|
||||||
|
).first()
|
||||||
|
if incomplete_entry:
|
||||||
|
raise ValidationError('Cannot create new entry while incomplete entry exists')
|
||||||
|
# Check for time overlap with existing entries
|
||||||
|
overlapping_entry = TimeEntry.query.filter(
|
||||||
|
TimeEntry.personal_id == user_id,
|
||||||
|
TimeEntry.datum == data['date'],
|
||||||
|
TimeEntry.kommen <= data['checkIn'],
|
||||||
|
TimeEntry.gehen >= data['checkIn']
|
||||||
|
).first()
|
||||||
|
if overlapping_entry:
|
||||||
|
raise ValidationError('Time entry overlaps with existing entry')`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
databaseDesign: {
|
||||||
|
title: 'Datenbankdesign',
|
||||||
|
description: 'Das MSSQL-Datenbankschema ist auf Effizienz und Integrität ausgelegt:',
|
||||||
|
code: `CREATE TABLE dbo.Stundenzettel (
|
||||||
|
ID decimal NOT NULL PRIMARY KEY,
|
||||||
|
Personal_ID decimal,
|
||||||
|
Datum date,
|
||||||
|
Kommen time,
|
||||||
|
Gehen time,
|
||||||
|
xStatus int,
|
||||||
|
xBenutzer nvarchar(15),
|
||||||
|
xDatum datetime,
|
||||||
|
xVersion timestamp
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_personal_datum
|
||||||
|
ON dbo.Stundenzettel(Personal_ID, Datum);`
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
title: 'Sicherheitsimplementierung',
|
||||||
|
authentication: {
|
||||||
|
title: 'JWT-Authentifizierung',
|
||||||
|
code: `# auth/jwt_handler.py
|
||||||
|
from flask_jwt_extended import create_access_token
|
||||||
|
def authenticate_user(username: str, password: str) -> str:
|
||||||
|
user = Personal.query.filter_by(
|
||||||
|
Benutzername=username,
|
||||||
|
Passwort=password # In production, use proper password hashing
|
||||||
|
).first()
|
||||||
|
if not user:
|
||||||
|
raise AuthenticationError('Invalid credentials')
|
||||||
|
return create_access_token(identity=user.ID)`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bestPractices: {
|
||||||
|
title: 'Best Practices und Learnings',
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
title: 'Datenvalidierung auf mehreren Ebenen',
|
||||||
|
items: [
|
||||||
|
'Frontend-Validierung für sofortiges Feedback',
|
||||||
|
'Backend-Validierung für Geschäftsregeln',
|
||||||
|
'Datenbankconstraints für Datenintegrität'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fehlerbehandlung',
|
||||||
|
items: [
|
||||||
|
'Strukturierte Fehlermeldungen',
|
||||||
|
'Benutzerfreundliche Fehlermeldungen im Frontend',
|
||||||
|
'Detailliertes Logging im Backend'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Performance-Optimierung',
|
||||||
|
items: [
|
||||||
|
'Indexierung kritischer Datenbankfelder',
|
||||||
|
'Frontend-Caching von Zeiteinträgen',
|
||||||
|
'Lazy Loading für historische Daten'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
challenges: {
|
||||||
|
title: 'Herausforderungen und Lösungen',
|
||||||
|
timezones: {
|
||||||
|
title: '1. Zeitzonen-Handling',
|
||||||
|
description: 'Eine besondere Herausforderung war die korrekte Behandlung von Zeitzonen:',
|
||||||
|
code: `// utils/dateTime.ts
|
||||||
|
export const formatTimeForDisplay = (time: string): string => {
|
||||||
|
return new Date(\`1970-01-01T\${time}\`).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const formatTimeForAPI = (time: string): string => {
|
||||||
|
return new Date(\`1970-01-01T\${time}\`).toISOString().split('T')[1];
|
||||||
|
};`
|
||||||
|
},
|
||||||
|
concurrency: {
|
||||||
|
title: '2. Concurrent Updates',
|
||||||
|
description: 'Die Behandlung gleichzeitiger Updates erforderte spezielle Aufmerksamkeit:',
|
||||||
|
code: `from sqlalchemy import and_, or_
|
||||||
|
def update_time_entry(entry_id: int, data: dict) -> TimeEntry:
|
||||||
|
entry = TimeEntry.query.filter_by(id=entry_id).with_for_update().first()
|
||||||
|
if not entry:
|
||||||
|
raise NotFoundError('Time entry not found')
|
||||||
|
# Optimistic locking using version field
|
||||||
|
if entry.xVersion != data['version']:
|
||||||
|
raise ConcurrencyError('Entry was modified by another user')
|
||||||
|
entry.gehen = data.get('checkOut')
|
||||||
|
db.session.commit()
|
||||||
|
return entry`
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: '3. Offline-Fähigkeit',
|
||||||
|
description: 'Für die Offline-Funktionalität implementierten wir eine Service Worker-Strategie:',
|
||||||
|
code: `// service-worker.ts
|
||||||
|
const CACHE_NAME = 'timetracking-v1';
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(response => {
|
||||||
|
return response || fetch(event.request).then(response => {
|
||||||
|
return caches.open(CACHE_NAME).then(cache => {
|
||||||
|
cache.put(event.request, response.clone());
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
conclusion: {
|
||||||
|
title: 'Fazit',
|
||||||
|
description: 'Die Entwicklung einer Zeiterfassungslösung erfordert sorgfältige Planung und Berücksichtigung zahlreicher Geschäftsregeln. Durch den Einsatz moderner Technologien wie Next.js, TypeScript und Flask konnten wir eine robuste und benutzerfreundliche Lösung implementieren.',
|
||||||
|
keyPoints: [
|
||||||
|
'Strikte Typisierung mit TypeScript',
|
||||||
|
'Umfassende Validierungslogik',
|
||||||
|
'Effizientes Datenbankdesign',
|
||||||
|
'Benutzerfreundliche Fehlerbehandlung'
|
||||||
|
],
|
||||||
|
results: 'Die Lösung ist seit mehreren Monaten erfolgreich im Einsatz und verarbeitet täglich hunderte von Zeiteinträgen zuverlässig.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// src/i18n/locales/de/blog/posts/rfid-automation.ts
|
||||||
|
export const rfidAutomation = {
|
||||||
|
meta: {
|
||||||
|
title: 'RFID-Etiketten-Automatisierung: Von Datenbank zum Drucker',
|
||||||
|
date: '2024-02-09',
|
||||||
|
excerpt: 'Eine detaillierte technische Analyse der Implementierung eines automatisierten RFID-Etikettendruck-Systems mit Python',
|
||||||
|
category: 'System Integration',
|
||||||
|
coverImage: '/images/posts/rfid-automation/cover.jpg',
|
||||||
|
tags: ['RFID', 'Python', 'ZPL', 'Automation', 'Zebra Printer', 'EPC']
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
intro: {
|
||||||
|
title: 'RFID-Etiketten-Automatisierung: Von Datenbank zum Drucker',
|
||||||
|
description: 'In modernen Logistik- und Retail-Umgebungen ist die effiziente und fehlerfreie Etikettierung von Produkten mit RFID-Tags von entscheidender Bedeutung. In diesem Artikel teile ich meine Erfahrungen bei der Implementierung einer automatisierten Lösung für die Generierung und den Druck von RFID-Etiketten mit Chipkodierung.'
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
title: 'Projekthintergrund',
|
||||||
|
points: [
|
||||||
|
'Automatische Generierung von EPC-Codes (Electronic Product Code)',
|
||||||
|
'Erstellung von ZPL-Code für Zebra-Drucker',
|
||||||
|
'Direkte Netzwerkkommunikation mit dem Drucker',
|
||||||
|
'Integration mit bestehenden Produktdaten',
|
||||||
|
'Fehlerbehandlung und Logging'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
implementation: {
|
||||||
|
title: 'Technische Umsetzung',
|
||||||
|
epcGeneration: {
|
||||||
|
title: 'EPC-Code Generierung',
|
||||||
|
description: 'Die EPC-Codes werden nach dem SGTIN-Standard (Serialized Global Trade Item Number) generiert:',
|
||||||
|
code: `def generate_encoded_epc(company_prefix, indicator, item_ref, serial):
|
||||||
|
sgtin = SGTIN(company_prefix, indicator, item_ref, serial)
|
||||||
|
return sgtin.encode()`
|
||||||
|
},
|
||||||
|
zplTemplates: {
|
||||||
|
title: 'ZPL-Template Management',
|
||||||
|
description: 'Für die unterschiedlichen Etikettentypen wurden ZPL-Templates implementiert:',
|
||||||
|
code: `def generate_zpl_code(artikelnr, description, ean, price, material, water_resistance, glass_type, encoded_epc):
|
||||||
|
formatted_price = f'{price:.2f}'
|
||||||
|
return f'''
|
||||||
|
CT~~CD,~CC^~CT~
|
||||||
|
^XA
|
||||||
|
~TA000
|
||||||
|
~JSN
|
||||||
|
^LT35
|
||||||
|
^MNW
|
||||||
|
^MTT
|
||||||
|
^PON
|
||||||
|
^PMN
|
||||||
|
^LH0,0
|
||||||
|
^JMA
|
||||||
|
^PR2,2
|
||||||
|
~SD23
|
||||||
|
^JUS
|
||||||
|
^LRN
|
||||||
|
^CI27
|
||||||
|
^PA0,1,1,0
|
||||||
|
^RS8,,,3
|
||||||
|
^XZ
|
||||||
|
^XA
|
||||||
|
^MMT
|
||||||
|
^PW413
|
||||||
|
^LL531
|
||||||
|
^LS-24
|
||||||
|
^FT150,57^A0N,33,33^FH\\^CI27^FD{artikelnr}^FS^CI27
|
||||||
|
^FT44,86^A0N,21,20^FH\\^CI27^FD{description}^FS^CI27
|
||||||
|
^FT130,141^A0N,50,51^FH\\^CI27^FD{formatted_price} €^FS^CI27
|
||||||
|
^FT167,175^A0N,21,20^FH\\^CI27^FD{material}^FS^CI27
|
||||||
|
^FT185,201^A0N,21,20^FH\\^CI27^FD{water_resistance}^FS^CI27
|
||||||
|
^FT155,227^A0N,21,20^FH\\^CI27^FD{glass_type}^FS^CI27
|
||||||
|
^BY3,2,113^FT73,420^BEN,,Y,N
|
||||||
|
^FH\\^FD{ean}^FS
|
||||||
|
^RFW,H,1,2,1^FD3000^FS
|
||||||
|
^RFW,H,2,12,1^FD{encoded_epc}^FS
|
||||||
|
^PQ1,0,1,Y
|
||||||
|
^XZ'''`
|
||||||
|
},
|
||||||
|
printerCommunication: {
|
||||||
|
title: 'Netzwerkkommunikation mit dem Drucker',
|
||||||
|
description: 'Die Kommunikation mit dem Zebra-Drucker erfolgt über TCP/IP:',
|
||||||
|
code: `def send_to_printer(data, printer_ip='192.168.68.50', printer_port=9100):
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.connect((printer_ip, printer_port))
|
||||||
|
sock.sendall(data.encode('utf-8'))
|
||||||
|
logger.info('Daten erfolgreich an den Drucker gesendet')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Fehler beim Senden der Daten an den Drucker: {e}')`
|
||||||
|
},
|
||||||
|
serialNumberManagement: {
|
||||||
|
title: 'Datenprozessierung und Seriennummernverwaltung',
|
||||||
|
description: 'Die Verwaltung von Seriennummern ist kritisch für die Eindeutigkeit der EPC-Codes:',
|
||||||
|
code: `def initialize_serial_number():
|
||||||
|
global current_serial_number
|
||||||
|
try:
|
||||||
|
serial_log_content = SERIAL_NUMBER_LOG_PATH.read_text().strip()
|
||||||
|
current_serial_number = int(serial_log_content)
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
current_serial_number = START_SERIAL_NUMBER
|
||||||
|
def get_next_serial_number():
|
||||||
|
global current_serial_number
|
||||||
|
current_serial_number += 1
|
||||||
|
return current_serial_number
|
||||||
|
def finalize_serial_number():
|
||||||
|
SERIAL_NUMBER_LOG_PATH.write_text(str(current_serial_number))`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
title: 'Implementierte Features',
|
||||||
|
errorHandling: {
|
||||||
|
title: '1. Robuste Fehlerbehandlung',
|
||||||
|
points: [
|
||||||
|
'Validierung der Eingabedaten',
|
||||||
|
'Überprüfung der Netzwerkverbindung',
|
||||||
|
'Persistenz von Seriennummern',
|
||||||
|
'Detailliertes Logging aller Operationen'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
configurability: {
|
||||||
|
title: '2. Konfigurierbarkeit',
|
||||||
|
points: [
|
||||||
|
'Drucker-IP und Port einstellbar',
|
||||||
|
'Anpassbare ZPL-Templates',
|
||||||
|
'Flexible EPC-Code-Generierung',
|
||||||
|
'Konfigurierbare Seriennummernbereiche'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
scalability: {
|
||||||
|
title: '3. Skalierbarkeit',
|
||||||
|
points: [
|
||||||
|
'Batch-Verarbeitung von Produktdaten',
|
||||||
|
'Parallele Druckaufträge möglich',
|
||||||
|
'Effizientes Ressourcenmanagement',
|
||||||
|
'Modularer Code für einfache Erweiterbarkeit'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bestPractices: {
|
||||||
|
title: 'Best Practices',
|
||||||
|
dataValidation: {
|
||||||
|
title: '1. Datenvalidierung',
|
||||||
|
points: [
|
||||||
|
'Strict typing für kritische Datenfelder',
|
||||||
|
'Überprüfung von Pflichtfeldern',
|
||||||
|
'Format- und Plausibilitätsprüfungen'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
faultTolerance: {
|
||||||
|
title: '2. Fehlertoleranz',
|
||||||
|
points: [
|
||||||
|
'Automatische Wiederholungsversuche',
|
||||||
|
'Graceful Degradation',
|
||||||
|
'Detaillierte Fehlerprotokolle'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
maintainability: {
|
||||||
|
title: '3. Wartbarkeit',
|
||||||
|
points: [
|
||||||
|
'Modularer Code-Aufbau',
|
||||||
|
'Ausführliche Dokumentation',
|
||||||
|
'Klare Trennung von Konfiguration und Code'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lessonsLearned: {
|
||||||
|
title: 'Lessons Learned',
|
||||||
|
zplSpecifics: {
|
||||||
|
title: '1. ZPL-Spezifika',
|
||||||
|
points: [
|
||||||
|
'Genaue Kenntnis der ZPL-Spezifikationen notwendig',
|
||||||
|
'Sorgfältige Validierung der generierten ZPL-Codes',
|
||||||
|
'Regelmäßige Tests mit verschiedenen Druckermodellen'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
rfidStandards: {
|
||||||
|
title: '2. RFID-Standards',
|
||||||
|
points: [
|
||||||
|
'Einhaltung der EPC-Standards kritisch',
|
||||||
|
'Sorgfältige Verwaltung von Seriennummern',
|
||||||
|
'Validierung der generierten EPC-Codes'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
networkCommunication: {
|
||||||
|
title: '3. Netzwerkkommunikation',
|
||||||
|
points: [
|
||||||
|
'Robuste Fehlerbehandlung bei Netzwerkproblemen',
|
||||||
|
'Timeouts und Wiederholungsversuche',
|
||||||
|
'Pufferung von Druckaufträgen'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
conclusion: {
|
||||||
|
title: 'Fazit',
|
||||||
|
description: 'Die implementierte Lösung ermöglicht eine effiziente und zuverlässige Automatisierung des RFID-Etikettierungsprozesses. Durch die Kombination von EPC-Code-Generierung, ZPL-Template-Management und direkter Druckerkommunikation wurde ein robustes System geschaffen, das sich in der Praxis bewährt hat.',
|
||||||
|
keyPoints: [
|
||||||
|
'Gründliche Planung der Systemarchitektur',
|
||||||
|
'Umfassende Fehlerbehandlung',
|
||||||
|
'Sorgfältige Dokumentation',
|
||||||
|
'Regelmäßige Tests und Validierung'
|
||||||
|
],
|
||||||
|
results: 'Die Lösung läuft seit mehreren Monaten stabil im Produktivbetrieb und verarbeitet täglich hunderte von Etiketten.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export const common = {
|
||||||
|
// SEO & Meta
|
||||||
|
siteTitle: 'Damjan Savić - Digital Solutions & JTL Integration',
|
||||||
|
siteDescription: 'Entwickler individueller Lösungen zur Automatisierung von Prozessen',
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
home: 'Start',
|
||||||
|
about: 'Über mich',
|
||||||
|
services: 'Leistungen',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
contact: 'Kontakt'
|
||||||
|
},
|
||||||
|
// Common Actions
|
||||||
|
actions: {
|
||||||
|
readMore: 'Weiterlesen',
|
||||||
|
viewMore: 'Mehr anzeigen',
|
||||||
|
viewAll: 'Alle anzeigen',
|
||||||
|
back: 'Zurück',
|
||||||
|
close: 'Schließen',
|
||||||
|
submit: 'Absenden',
|
||||||
|
loading: 'Lädt...'
|
||||||
|
},
|
||||||
|
// UI Components
|
||||||
|
ui: {
|
||||||
|
published: 'Veröffentlicht am',
|
||||||
|
lastUpdated: 'Zuletzt aktualisiert am',
|
||||||
|
category: 'Kategorie',
|
||||||
|
categories: 'Kategorien',
|
||||||
|
tag: 'Tag',
|
||||||
|
tags: 'Tags',
|
||||||
|
author: 'Autor',
|
||||||
|
minuteRead: 'Minuten Lesezeit',
|
||||||
|
tableOfContents: 'Inhaltsverzeichnis'
|
||||||
|
},
|
||||||
|
// Error Messages
|
||||||
|
errors: {
|
||||||
|
generic: 'Ein Fehler ist aufgetreten',
|
||||||
|
notFound: 'Seite nicht gefunden',
|
||||||
|
loading: 'Fehler beim Laden',
|
||||||
|
return: 'Zurück zur Startseite'
|
||||||
|
},
|
||||||
|
// Loading States
|
||||||
|
loading: {
|
||||||
|
content: 'Inhalt wird geladen',
|
||||||
|
page: 'Seite wird geladen',
|
||||||
|
processing: 'Wird verarbeitet'
|
||||||
|
},
|
||||||
|
// Dates
|
||||||
|
dates: {
|
||||||
|
months: {
|
||||||
|
long: [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||||
|
],
|
||||||
|
short: [
|
||||||
|
'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
weekdays: {
|
||||||
|
long: [
|
||||||
|
'Sonntag', 'Montag', 'Dienstag', 'Mittwoch',
|
||||||
|
'Donnerstag', 'Freitag', 'Samstag'
|
||||||
|
],
|
||||||
|
short: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Social Media
|
||||||
|
social: {
|
||||||
|
followMe: 'Folgen Sie mir auf',
|
||||||
|
shareOn: 'Teilen auf',
|
||||||
|
linkedin: 'LinkedIn',
|
||||||
|
github: 'GitHub',
|
||||||
|
email: 'E-Mail'
|
||||||
|
},
|
||||||
|
// Contact Form
|
||||||
|
contact: {
|
||||||
|
title: 'Kontakt',
|
||||||
|
name: 'Name',
|
||||||
|
email: 'E-Mail',
|
||||||
|
message: 'Nachricht',
|
||||||
|
submit: 'Nachricht senden',
|
||||||
|
success: 'Ihre Nachricht wurde erfolgreich gesendet',
|
||||||
|
error: 'Es gab einen Fehler beim Senden Ihrer Nachricht'
|
||||||
|
},
|
||||||
|
// Accessibility
|
||||||
|
a11y: {
|
||||||
|
skipToContent: 'Zum Hauptinhalt springen',
|
||||||
|
menuOpen: 'Menü öffnen',
|
||||||
|
menuClose: 'Menü schließen',
|
||||||
|
darkMode: 'Dark Mode umschalten',
|
||||||
|
carousel: {
|
||||||
|
next: 'Nächstes Bild',
|
||||||
|
previous: 'Vorheriges Bild'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
export type CommonTranslations = typeof common;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const cookiebanner = {
|
||||||
|
cookies: {
|
||||||
|
message: "Diese Website verwendet Cookies, um Ihr Browsing-Erlebnis zu verbessern. Die Daten werden nicht an Dritte weitergegeben.",
|
||||||
|
accept: "Cookies akzeptieren",
|
||||||
|
decline: "Ablehnen"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default cookiebanner;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// src/i18n/locales/de/footer.ts
|
||||||
|
const footer = {
|
||||||
|
sections: {
|
||||||
|
contact: {
|
||||||
|
title: "Kontakt",
|
||||||
|
email: "info@damjan-savic.com",
|
||||||
|
location: "Köln, Deutschland",
|
||||||
|
aria: {
|
||||||
|
emailLink: "E-Mail senden",
|
||||||
|
locationText: "Standort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
title: "Navigation",
|
||||||
|
links: {
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
blog: "Blog",
|
||||||
|
about: "Über mich",
|
||||||
|
contact: "Kontakt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
title: "Social Media",
|
||||||
|
aria: {
|
||||||
|
linkedin: "Besuchen Sie mein LinkedIn Profil",
|
||||||
|
github: "Besuchen Sie mein GitHub Profil"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
copyright: "Alle Rechte vorbehalten",
|
||||||
|
links: {
|
||||||
|
privacy: "Datenschutz",
|
||||||
|
terms: "Impressum"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default footer;
|
||||||
|
export type FooterTranslations = typeof footer;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// src/i18n/locales/de/index.ts
|
||||||
|
|
||||||
|
// Blog post translations
|
||||||
|
import { erpIntegrationBreuninger } from './blog/posts/erp-integration-breuninger';
|
||||||
|
import { fullstackDevelopmentTimetracking } from './blog/posts/fullstack-development-timetracking';
|
||||||
|
import { rfidAutomation as blogRfidAutomation } from './blog/posts/rfid-automation';
|
||||||
|
import { blog } from './blog/index';
|
||||||
|
|
||||||
|
// Page translations
|
||||||
|
import { about } from './pages/about/index';
|
||||||
|
import { contact } from './pages/contact/index';
|
||||||
|
import { dashboard } from './pages/dashboard';
|
||||||
|
import { home } from './pages/home';
|
||||||
|
import { login } from './pages/login';
|
||||||
|
import { notfound } from './pages/notfound';
|
||||||
|
import { portfolio as portfolioPage } from './pages/portfolio';
|
||||||
|
import { privacy } from './pages/privacy';
|
||||||
|
import { terms } from './pages/terms';
|
||||||
|
|
||||||
|
// Portfolio project translations
|
||||||
|
import { aiDataReader } from './portfolio/projects/ai-data-reader';
|
||||||
|
import { portfolio } from './portfolio/index';
|
||||||
|
|
||||||
|
// Common translations and configurations
|
||||||
|
import { common } from './common';
|
||||||
|
import { meta } from './meta';
|
||||||
|
import { navigation } from './navigation';
|
||||||
|
|
||||||
|
// Footer translations
|
||||||
|
import footer from './footer';
|
||||||
|
|
||||||
|
// Zusammenführung aller Übersetzungsobjekte
|
||||||
|
const translations = {
|
||||||
|
// Pages
|
||||||
|
pages: {
|
||||||
|
about,
|
||||||
|
contact,
|
||||||
|
dashboard,
|
||||||
|
home,
|
||||||
|
login,
|
||||||
|
notfound,
|
||||||
|
portfolio: portfolioPage,
|
||||||
|
privacy,
|
||||||
|
terms,
|
||||||
|
},
|
||||||
|
// Blog
|
||||||
|
blog: {
|
||||||
|
...blog,
|
||||||
|
posts: {
|
||||||
|
erpIntegrationBreuninger,
|
||||||
|
fullstackDevelopmentTimetracking,
|
||||||
|
rfidAutomation: blogRfidAutomation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Portfolio
|
||||||
|
portfolio: {
|
||||||
|
...portfolio,
|
||||||
|
projects: {
|
||||||
|
aiDataReader,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Footer
|
||||||
|
footer,
|
||||||
|
// Gemeinsame Konfigurationen
|
||||||
|
common: {
|
||||||
|
...common,
|
||||||
|
cookies: {
|
||||||
|
message: "Diese Website verwendet Cookies, um Ihr Browsing-Erlebnis zu verbessern. Die Daten werden nicht an Dritte weitergegeben.",
|
||||||
|
accept: "Cookies akzeptieren",
|
||||||
|
decline: "Ablehnen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
meta,
|
||||||
|
navigation,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Default-Export: komplettes Übersetzungsobjekt
|
||||||
|
export default translations;
|
||||||
|
|
||||||
|
// Typdefinition der Übersetzungen
|
||||||
|
export type Translations = typeof translations;
|
||||||
|
|
||||||
|
// Re-Exports für den direkten Zugriff auf einzelne Module
|
||||||
|
export {
|
||||||
|
// Pages
|
||||||
|
about,
|
||||||
|
contact,
|
||||||
|
dashboard,
|
||||||
|
home,
|
||||||
|
login,
|
||||||
|
notfound,
|
||||||
|
portfolioPage as portfolioPageTranslations,
|
||||||
|
privacy,
|
||||||
|
terms,
|
||||||
|
// Blog
|
||||||
|
blog,
|
||||||
|
erpIntegrationBreuninger,
|
||||||
|
fullstackDevelopmentTimetracking,
|
||||||
|
blogRfidAutomation as blogRfidAutomationPost,
|
||||||
|
// Portfolio
|
||||||
|
portfolio,
|
||||||
|
aiDataReader,
|
||||||
|
// Footer
|
||||||
|
footer,
|
||||||
|
// Common
|
||||||
|
common,
|
||||||
|
meta,
|
||||||
|
navigation,
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export const meta = {
|
||||||
|
site: {
|
||||||
|
name: 'Damjan Savić',
|
||||||
|
title: 'Damjan Savić - Digital Solutions & JTL Integration',
|
||||||
|
subtitle: 'Entwickler individueller Lösungen zur Automatisierung von Prozessen',
|
||||||
|
description: 'Mit einem Hintergrund in der Softwareentwicklung und Expertise in der digitalen Transformation unterstütze ich Unternehmen bei der Optimierung und Automatisierung ihrer Geschäftsprozesse.',
|
||||||
|
keywords: [
|
||||||
|
'JTL Integration',
|
||||||
|
'E-Commerce Lösungen',
|
||||||
|
'Prozessautomatisierung',
|
||||||
|
'Digitale Beratung',
|
||||||
|
'ERP Systeme',
|
||||||
|
'Python Entwicklung',
|
||||||
|
'Systemintegration',
|
||||||
|
'Digitales Marketing'
|
||||||
|
].join(', ')
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
name: 'CoderConda',
|
||||||
|
shortName: 'CoderConda',
|
||||||
|
description: 'Ihr JTL-Servicepartner für schnelle, verbindliche und kosteneffiziente Lösungen.',
|
||||||
|
address: 'Rotdornallee',
|
||||||
|
city: 'Köln',
|
||||||
|
postalCode: '50999',
|
||||||
|
country: 'Deutschland',
|
||||||
|
phone: '+49 175 695 0979',
|
||||||
|
email: 'info@damjan-savic.com',
|
||||||
|
website: 'www.damjan-savic.com'
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
name: 'Damjan Savić',
|
||||||
|
role: 'Consultant Digital Solutions',
|
||||||
|
company: 'CoderConda',
|
||||||
|
location: 'Köln, Deutschland',
|
||||||
|
email: 'info@damjan-savic.com',
|
||||||
|
website: 'https://www.damjan-savic.com',
|
||||||
|
languages: [
|
||||||
|
{ code: 'en', level: 'C2', name: 'Englisch' },
|
||||||
|
{ code: 'sr', level: 'C2', name: 'Serbisch' },
|
||||||
|
{ code: 'de', level: 'C2', name: 'Deutsch' },
|
||||||
|
{ code: 'fr', level: 'B2', name: 'Französisch' },
|
||||||
|
{ code: 'es', level: 'B2', name: 'Spanisch' },
|
||||||
|
{ code: 'ru', level: 'A1', name: 'Russisch' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
linkedin: 'https://www.linkedin.com/in/damjansavic/',
|
||||||
|
github: 'https://github.com/damjansavic',
|
||||||
|
email: 'info@damjan-savic.com'
|
||||||
|
},
|
||||||
|
expertise: {
|
||||||
|
areas: [
|
||||||
|
'JTL Integration & Beratung',
|
||||||
|
'Prozessautomatisierung',
|
||||||
|
'E-Commerce Entwicklung',
|
||||||
|
'Systemintegration',
|
||||||
|
'ERP-Systeme',
|
||||||
|
'Digitale Transformation'
|
||||||
|
],
|
||||||
|
technologies: [
|
||||||
|
'Python',
|
||||||
|
'React/Next.js',
|
||||||
|
'MariaDB',
|
||||||
|
'Docker',
|
||||||
|
'JTL-WaWi',
|
||||||
|
'Shopify',
|
||||||
|
'Shopware'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
defaultLanguage: 'de',
|
||||||
|
alternateLanguages: ['en', 'sr'],
|
||||||
|
robots: 'index, follow',
|
||||||
|
googleSiteVerification: '', // Falls benötigt
|
||||||
|
og: {
|
||||||
|
type: 'website',
|
||||||
|
locale: 'de_DE',
|
||||||
|
siteName: 'Damjan Savić - Digital Solutions',
|
||||||
|
images: {
|
||||||
|
url: '/images/og-image.jpg',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'Damjan Savić - Digital Solutions & JTL Integration'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
site: '@damjansavic',
|
||||||
|
creator: '@damjansavic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
export type MetaConfig = typeof meta;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// C:\Development\Damjan Savic\Portfolio\src\i18n\locales\de\navigation.ts
|
||||||
|
export const navigation = {
|
||||||
|
// Hauptnavigation
|
||||||
|
main: {
|
||||||
|
home: 'Startseite',
|
||||||
|
about: 'Über mich',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
contact: 'Kontakt'
|
||||||
|
},
|
||||||
|
// Dashboard und Admin
|
||||||
|
admin: {
|
||||||
|
login: 'Anmelden',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
logout: 'Abmelden'
|
||||||
|
},
|
||||||
|
// Footer Navigation
|
||||||
|
footer: {
|
||||||
|
legal: {
|
||||||
|
privacy: 'Datenschutz',
|
||||||
|
terms: 'AGB',
|
||||||
|
imprint: 'Impressum'
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
contact: 'Kontakt'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Seitennavigation
|
||||||
|
sections: {
|
||||||
|
experience: 'Erfahrung',
|
||||||
|
skills: 'Fähigkeiten',
|
||||||
|
projects: 'Projekte',
|
||||||
|
about: 'Über mich'
|
||||||
|
},
|
||||||
|
// Breadcrumbs und zurück-Navigation
|
||||||
|
navigation: {
|
||||||
|
backTo: {
|
||||||
|
home: 'Zurück zur Startseite',
|
||||||
|
portfolio: 'Zurück zum Portfolio',
|
||||||
|
blog: 'Zurück zum Blog'
|
||||||
|
},
|
||||||
|
breadcrumbs: {
|
||||||
|
home: 'Startseite',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
about: 'Über mich',
|
||||||
|
contact: 'Kontakt'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Mobile Navigation
|
||||||
|
mobile: {
|
||||||
|
menu: 'Menü',
|
||||||
|
close: 'Schließen',
|
||||||
|
open: 'Menü öffnen'
|
||||||
|
},
|
||||||
|
// URLs (für dynamische Route-Generierung)
|
||||||
|
urls: {
|
||||||
|
home: '/',
|
||||||
|
about: '/about',
|
||||||
|
portfolio: '/portfolio',
|
||||||
|
blog: '/blog',
|
||||||
|
contact: '/contact',
|
||||||
|
login: '/login',
|
||||||
|
dashboard: '/dashboard',
|
||||||
|
privacy: '/privacy',
|
||||||
|
terms: '/terms'
|
||||||
|
},
|
||||||
|
// Meta-Informationen für Navigation
|
||||||
|
meta: {
|
||||||
|
main: 'Hauptnavigation',
|
||||||
|
footer: 'Footer Navigation',
|
||||||
|
social: 'Social Media Navigation',
|
||||||
|
legal: 'Rechtliche Navigation'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NavigationConfig = typeof navigation;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const hero = {
|
||||||
|
// Navigation/Header
|
||||||
|
aboutMe: 'ÜBER MICH',
|
||||||
|
// Main Section
|
||||||
|
title: 'Digital Solutions Experte',
|
||||||
|
description: 'Mit einem Hintergrund in Softwareentwicklung und Expertise in digitaler Transformation helfe ich Unternehmen ihre Prozesse durch innovative Lösungen zu optimieren und zu automatisieren.',
|
||||||
|
// Expertise Areas
|
||||||
|
expertise: {
|
||||||
|
processAutomation: {
|
||||||
|
title: 'Prozessautomatisierung',
|
||||||
|
description: 'Entwicklung effizienter automatisierter Workflows'
|
||||||
|
},
|
||||||
|
systemIntegration: {
|
||||||
|
title: 'Systemintegration',
|
||||||
|
description: 'Verbindung und Optimierung von Geschäftssystemen'
|
||||||
|
},
|
||||||
|
customDevelopment: {
|
||||||
|
title: 'Individuelle Entwicklung',
|
||||||
|
description: 'Erstellung maßgeschneiderter Softwarelösungen'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Languages
|
||||||
|
languages: {
|
||||||
|
english: 'Englisch',
|
||||||
|
serbian: 'Serbisch',
|
||||||
|
german: 'Deutsch',
|
||||||
|
french: 'Französisch',
|
||||||
|
spanish: 'Spanisch',
|
||||||
|
russian: 'Russisch'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type HeroTranslations = typeof hero;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { hero } from './hero';
|
||||||
|
import { journey } from './journey';
|
||||||
|
import { schema } from './schema';
|
||||||
|
import { skillbar } from './skillbar';
|
||||||
|
import { skillgroup } from './skillgroup';
|
||||||
|
import { skills } from './skills';
|
||||||
|
import { workflow } from './workflow';
|
||||||
|
export const about = {
|
||||||
|
hero,
|
||||||
|
journey,
|
||||||
|
schema,
|
||||||
|
skillbar,
|
||||||
|
skillgroup,
|
||||||
|
skills,
|
||||||
|
workflow
|
||||||
|
} as const;
|
||||||
|
// Typen exportieren
|
||||||
|
export type { HeroTranslations } from './hero';
|
||||||
|
export type { JourneyTranslations } from './journey';
|
||||||
|
export type { SchemaTranslations } from './schema';
|
||||||
|
export type { SkillBarTranslations } from './skillbar';
|
||||||
|
export type { SkillGroupTranslations } from './skillgroup';
|
||||||
|
export type { SkillsTranslations } from './skills';
|
||||||
|
export type { WorkflowTranslations } from './workflow';
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
export const journey = {
|
||||||
|
// Header
|
||||||
|
title: 'Beruflicher Werdegang',
|
||||||
|
subtitle: 'Von digitalen Medien zu Unternehmenslösungen',
|
||||||
|
// Education Section
|
||||||
|
education: {
|
||||||
|
title: 'Ausbildung',
|
||||||
|
degrees: {
|
||||||
|
masters: {
|
||||||
|
degree: 'M.A. Softwareentwicklung',
|
||||||
|
university: 'Middlesex University, London',
|
||||||
|
period: '2020 - 2022'
|
||||||
|
},
|
||||||
|
bachelors: {
|
||||||
|
degree: 'B.A. Cross-Media Produktion',
|
||||||
|
university: 'Middlesex University, London',
|
||||||
|
period: '2018 - 2020'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Languages Section
|
||||||
|
languages: {
|
||||||
|
title: 'Sprachen',
|
||||||
|
items: {
|
||||||
|
english: {
|
||||||
|
name: 'Englisch',
|
||||||
|
level: 'C2'
|
||||||
|
},
|
||||||
|
serbian: {
|
||||||
|
name: 'Serbisch',
|
||||||
|
level: 'C2'
|
||||||
|
},
|
||||||
|
german: {
|
||||||
|
name: 'Deutsch',
|
||||||
|
level: 'C2'
|
||||||
|
},
|
||||||
|
french: {
|
||||||
|
name: 'Französisch',
|
||||||
|
level: 'B2'
|
||||||
|
},
|
||||||
|
spanish: {
|
||||||
|
name: 'Spanisch',
|
||||||
|
level: 'B2'
|
||||||
|
},
|
||||||
|
russian: {
|
||||||
|
name: 'Russisch',
|
||||||
|
level: 'A1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Work History
|
||||||
|
work: {
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
period: '08/2023 - Heute',
|
||||||
|
company: 'Ritter Digital GmbH',
|
||||||
|
title: 'Digital Solutions Consultant',
|
||||||
|
location: 'Oberhausen',
|
||||||
|
highlights: [
|
||||||
|
'ERP-Beratung und -Implementierung (JTL WaWi)',
|
||||||
|
'Prozessautomatisierung & Full-Stack-Entwicklung',
|
||||||
|
'RFID-Technologie & Automatisierung',
|
||||||
|
'Lead-Generierung & -Management',
|
||||||
|
'KI-gestütztes Content-Management'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: '02/2023 - 08/2023',
|
||||||
|
company: 'Joyce & Girls Co AG',
|
||||||
|
title: 'ERP Integration Specialist',
|
||||||
|
location: 'Köln',
|
||||||
|
highlights: [
|
||||||
|
'Komplexe Systemintegration (Apparel Magic, TradeByte)',
|
||||||
|
'Server-Administration und Automatisierung',
|
||||||
|
'Datenbank- und API-Entwicklung',
|
||||||
|
'Digitales Marketing und Kampagnenanalyse'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: '08/2022 - 01/2023',
|
||||||
|
company: 'Magnarius',
|
||||||
|
title: 'RPA Developer & E-Commerce Manager',
|
||||||
|
location: 'Bergisch Gladbach',
|
||||||
|
highlights: [
|
||||||
|
'Shopware 6 Entwicklung und JTL-Integration',
|
||||||
|
'Google Ads Automatisierung',
|
||||||
|
'CMS-Management und -Optimierung',
|
||||||
|
'SEO-Strategieumsetzung'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type JourneyTranslations = typeof journey;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const schema = {
|
||||||
|
page: {
|
||||||
|
name: 'Über Damjan Savić',
|
||||||
|
description: 'Erfahren Sie mehr über Damjan Savić, einen zertifizierten JTL-Experten mit umfassender Erfahrung in E-Commerce und Warenwirtschaftslösungen.'
|
||||||
|
},
|
||||||
|
person: {
|
||||||
|
jobTitle: 'JTL Integrations-Experte',
|
||||||
|
description: 'Mit umfangreicher Erfahrung in der Integration und Optimierung von E-Commerce-Systemen unterstütze ich Unternehmen bei der digitalen Transformation ihrer Geschäftsprozesse.',
|
||||||
|
languages: {
|
||||||
|
english: 'Englisch',
|
||||||
|
serbian: 'Serbisch',
|
||||||
|
german: 'Deutsch',
|
||||||
|
french: 'Französisch',
|
||||||
|
spanish: 'Spanisch',
|
||||||
|
russian: 'Russisch'
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
python: 'Python',
|
||||||
|
googleAds: 'Google Ads',
|
||||||
|
metaAds: 'Meta Ads',
|
||||||
|
shopware: 'Shopware',
|
||||||
|
shopify: 'Shopify',
|
||||||
|
wooCommerce: 'WooCommerce',
|
||||||
|
jtlWawi: 'JTL WAWI',
|
||||||
|
serverAdmin: 'Server-Administration',
|
||||||
|
airflow: 'AirFlow'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type SchemaTranslations = typeof schema;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const skillbar = {
|
||||||
|
// Accessibility labels
|
||||||
|
ariaLabel: {
|
||||||
|
skillLevel: 'Fähigkeitsniveau',
|
||||||
|
progressBar: 'Fortschrittsbalken'
|
||||||
|
},
|
||||||
|
// Screen reader text
|
||||||
|
screenReader: {
|
||||||
|
progress: '{{level}} Prozent Fortschritt für {{skill}}'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type SkillBarTranslations = typeof skillbar;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export const skillgroup = {
|
||||||
|
// Kategorien
|
||||||
|
categories: {
|
||||||
|
development: 'Entwicklung',
|
||||||
|
ecommerce: 'E-Commerce',
|
||||||
|
marketing: 'Marketing',
|
||||||
|
automation: 'Automatisierung',
|
||||||
|
languages: 'Programmiersprachen',
|
||||||
|
databases: 'Datenbanken'
|
||||||
|
},
|
||||||
|
// Skills nach Kategorien
|
||||||
|
skills: {
|
||||||
|
development: {
|
||||||
|
frontend: 'Frontend-Entwicklung',
|
||||||
|
backend: 'Backend-Entwicklung',
|
||||||
|
fullstack: 'Full-Stack-Entwicklung',
|
||||||
|
api: 'API-Entwicklung',
|
||||||
|
testing: 'Software-Testing'
|
||||||
|
},
|
||||||
|
ecommerce: {
|
||||||
|
jtl: 'JTL WaWi',
|
||||||
|
shopware: 'Shopware',
|
||||||
|
shopify: 'Shopify',
|
||||||
|
woocommerce: 'WooCommerce',
|
||||||
|
magento: 'Magento'
|
||||||
|
},
|
||||||
|
marketing: {
|
||||||
|
seo: 'Suchmaschinenoptimierung',
|
||||||
|
sea: 'Suchmaschinenwerbung',
|
||||||
|
analytics: 'Web-Analyse',
|
||||||
|
googleAds: 'Google Ads',
|
||||||
|
metaAds: 'Meta Ads'
|
||||||
|
},
|
||||||
|
automation: {
|
||||||
|
rpa: 'Robotergestützte Prozessautomatisierung',
|
||||||
|
workflow: 'Workflow-Automatisierung',
|
||||||
|
cicd: 'CI/CD',
|
||||||
|
testing: 'Test-Automatisierung',
|
||||||
|
deployment: 'Deployment-Automatisierung'
|
||||||
|
},
|
||||||
|
languages: {
|
||||||
|
python: 'Python',
|
||||||
|
javascript: 'JavaScript',
|
||||||
|
typescript: 'TypeScript',
|
||||||
|
php: 'PHP',
|
||||||
|
sql: 'SQL'
|
||||||
|
},
|
||||||
|
databases: {
|
||||||
|
mysql: 'MySQL',
|
||||||
|
postgresql: 'PostgreSQL',
|
||||||
|
mongodb: 'MongoDB',
|
||||||
|
redis: 'Redis',
|
||||||
|
elasticsearch: 'Elasticsearch'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Accessibility
|
||||||
|
ariaLabels: {
|
||||||
|
skillGroup: 'Fähigkeitsgruppe',
|
||||||
|
skillList: 'Liste der Fähigkeiten in dieser Kategorie'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type SkillGroupTranslations = typeof skillgroup;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// de/pages/about/skills.ts
|
||||||
|
export const skills = {
|
||||||
|
title: 'Fähigkeiten',
|
||||||
|
skillGroups: [
|
||||||
|
{
|
||||||
|
category: 'Entwicklung',
|
||||||
|
items: [
|
||||||
|
{ name: 'Python', level: 90 },
|
||||||
|
{ name: 'Google Ads', level: 85 },
|
||||||
|
{ name: 'Meta Ads', level: 85 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'E-Commerce',
|
||||||
|
items: [
|
||||||
|
{ name: 'Shopware', level: 85 },
|
||||||
|
{ name: 'Shopify', level: 85 },
|
||||||
|
{ name: 'WooCommerce', level: 85 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Systemadministration',
|
||||||
|
items: [
|
||||||
|
{ name: 'JTL WAWI', level: 85 },
|
||||||
|
{ name: 'Server', level: 75 },
|
||||||
|
{ name: 'AirFlow', level: 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ui: {
|
||||||
|
percentage: 'Prozent',
|
||||||
|
levelLabel: 'Niveau',
|
||||||
|
skillLevel: '{{level}}% Beherrschung in {{skill}}'
|
||||||
|
},
|
||||||
|
aria: {
|
||||||
|
skillGroup: 'Fähigkeitsgruppe für {{category}}',
|
||||||
|
skillBar: 'Fortschrittsbalken für {{skill}}',
|
||||||
|
skillIcon: 'Symbol für {{category}}',
|
||||||
|
selectSkill: 'Fähigkeit auswählen'
|
||||||
|
},
|
||||||
|
screenReader: {
|
||||||
|
skillProgress: '{{skill}} mit einem Niveau von {{level}} Prozent'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SkillsTranslations = typeof skills;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const workflow = {
|
||||||
|
// Seitentitel
|
||||||
|
title: 'MEIN WORKFLOW',
|
||||||
|
description: 'Systematischer Ansatz zur Softwareentwicklung durch definierte Schritte',
|
||||||
|
// Workflow-Schritte
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
number: '01',
|
||||||
|
title: 'ANFORDERUNGSANALYSE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '02',
|
||||||
|
title: 'ABFRAGE VON DEADLINES UND EINSCHRÄNKUNGEN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '03',
|
||||||
|
title: 'EINRICHTUNG DER ENTWICKLUNGSUMGEBUNG'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '04',
|
||||||
|
title: 'PROGRAMMIERUNG'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '05',
|
||||||
|
title: 'TESTEN DER ERGEBNISSE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '06',
|
||||||
|
title: 'SUPPORT UND SKALIERUNG DES CODES'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Aria Labels für Barrierefreiheit
|
||||||
|
aria: {
|
||||||
|
workflowSection: 'Workflow-Prozess',
|
||||||
|
stepNumber: 'Schritt {{number}}',
|
||||||
|
stepDescription: 'Workflow-Schritt: {{title}}'
|
||||||
|
},
|
||||||
|
// Screen Reader Texte
|
||||||
|
screenReader: {
|
||||||
|
stepProgress: 'Schritt {{number}} von {{total}}: {{title}}'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type WorkflowTranslations = typeof workflow;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const contactform = {
|
||||||
|
// Formular Header
|
||||||
|
title: 'Kontaktieren Sie mich',
|
||||||
|
description: 'Haben Sie Fragen oder möchten Sie zusammenarbeiten? Schreiben Sie mir eine Nachricht.',
|
||||||
|
// Formularfelder
|
||||||
|
name: {
|
||||||
|
label: 'Name',
|
||||||
|
placeholder: 'Ihr vollständiger Name'
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
label: 'E-Mail',
|
||||||
|
placeholder: 'ihre.email@beispiel.de'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
label: 'Nachricht',
|
||||||
|
placeholder: 'Ihre Nachricht an mich...'
|
||||||
|
},
|
||||||
|
// Button
|
||||||
|
submit: 'Nachricht senden',
|
||||||
|
// Statusmeldungen
|
||||||
|
successMessage: 'Vielen Dank für Ihre Nachricht! Ich werde mich so schnell wie möglich bei Ihnen melden.',
|
||||||
|
errorMessage: 'Entschuldigung, beim Senden Ihrer Nachricht ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.',
|
||||||
|
// Fehlermeldungen
|
||||||
|
errors: {
|
||||||
|
nameRequired: 'Bitte geben Sie Ihren Namen ein',
|
||||||
|
emailRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
|
||||||
|
emailInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein',
|
||||||
|
messageRequired: 'Bitte geben Sie eine Nachricht ein'
|
||||||
|
},
|
||||||
|
// Aria Labels
|
||||||
|
aria: {
|
||||||
|
form: 'Kontaktformular',
|
||||||
|
submitting: 'Formular wird gesendet',
|
||||||
|
successAlert: 'Erfolgsmeldung',
|
||||||
|
errorAlert: 'Fehlermeldung'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type ContactFormTranslations = typeof contactform;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const contactinfo = {
|
||||||
|
// Header
|
||||||
|
title: 'Kontaktinformationen',
|
||||||
|
description: 'Sie können mich über folgende Wege erreichen',
|
||||||
|
// Kontaktmethoden Labels
|
||||||
|
addressLabel: 'Bürostandort',
|
||||||
|
phoneLabel: 'Telefon',
|
||||||
|
emailLabel: 'E-Mail',
|
||||||
|
// Kontaktinformationen
|
||||||
|
address: 'Rotdornallee, Köln',
|
||||||
|
phone: '+49 175 695 0979',
|
||||||
|
email: 'info@damjan-savic.com',
|
||||||
|
// Zusätzliche Informationen
|
||||||
|
availabilityNote: 'Geschäftszeiten: Montag bis Freitag, 9:00 - 17:00 Uhr',
|
||||||
|
// Aria Labels
|
||||||
|
aria: {
|
||||||
|
contactCard: 'Kontaktinformationskarte',
|
||||||
|
addressLink: 'Öffne Standort in Google Maps',
|
||||||
|
phoneLink: 'Anrufen',
|
||||||
|
emailLink: 'E-Mail senden',
|
||||||
|
externalLink: 'Öffnet in neuem Tab'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
// Type für Typsicherheit
|
||||||
|
export type ContactInfoTranslations = typeof contactinfo;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// src/i18n/locales/de/pages/contact/index.ts
|
||||||
|
export const contact = {
|
||||||
|
breadcrumb: "KONTAKT",
|
||||||
|
hero: {
|
||||||
|
title: "Lassen Sie uns zusammenarbeiten",
|
||||||
|
subtitle: "Sie haben ein Projekt im Sinn? Lassen Sie uns besprechen, wie wir Ihnen bei der Erreichung Ihrer Ziele helfen können."
|
||||||
|
},
|
||||||
|
contactInfo: {
|
||||||
|
title: "Kontaktinformationen",
|
||||||
|
description: "Sie können mich über folgende Wege erreichen",
|
||||||
|
address: {
|
||||||
|
label: "Bürostandort",
|
||||||
|
value: "Rotdornallee, Köln"
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
label: "Telefon",
|
||||||
|
value: "+49 175 695 0979"
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
label: "E-Mail",
|
||||||
|
value: "info@damjan-savic.com"
|
||||||
|
},
|
||||||
|
availabilityNote: "Geschäftszeiten: Montag bis Freitag, 9:00 - 17:00 Uhr",
|
||||||
|
aria: {
|
||||||
|
contactCard: "Kontaktinformationskarte",
|
||||||
|
addressLink: "Öffne Standort in Google Maps",
|
||||||
|
phoneLink: "Anrufen",
|
||||||
|
emailLink: "E-Mail senden",
|
||||||
|
externalLink: "Öffnet in neuem Tab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contactForm: {
|
||||||
|
title: "Kontaktieren Sie mich",
|
||||||
|
description: "Haben Sie Fragen oder möchten Sie zusammenarbeiten? Schreiben Sie mir eine Nachricht.",
|
||||||
|
name: {
|
||||||
|
label: "Name",
|
||||||
|
placeholder: "Ihr vollständiger Name"
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
label: "E-Mail",
|
||||||
|
placeholder: "ihre.email@beispiel.de"
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
label: "Nachricht",
|
||||||
|
placeholder: "Ihre Nachricht an mich..."
|
||||||
|
},
|
||||||
|
submit: "Nachricht senden",
|
||||||
|
successMessage: "Vielen Dank für Ihre Nachricht! Ich werde mich so schnell wie möglich bei Ihnen melden.",
|
||||||
|
errorMessage: "Entschuldigung, beim Senden Ihrer Nachricht ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
|
||||||
|
errors: {
|
||||||
|
nameRequired: "Bitte geben Sie Ihren Namen ein",
|
||||||
|
emailRequired: "Bitte geben Sie Ihre E-Mail-Adresse ein",
|
||||||
|
emailInvalid: "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||||
|
messageRequired: "Bitte geben Sie eine Nachricht ein"
|
||||||
|
},
|
||||||
|
aria: {
|
||||||
|
form: "Kontaktformular",
|
||||||
|
submitting: "Formular wird gesendet",
|
||||||
|
successAlert: "Erfolgsmeldung",
|
||||||
|
errorAlert: "Fehlermeldung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ContactTranslations = typeof contact;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
pages: {
|
||||||
|
contact: contact
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// src/i18n/locales/de/pages/dashboard.ts
|
||||||
|
export const dashboard = {
|
||||||
|
// SEO
|
||||||
|
seo: {
|
||||||
|
title: 'Analytics Dashboard',
|
||||||
|
description: 'Website-Analysedashboard'
|
||||||
|
},
|
||||||
|
// Hauptüberschriften
|
||||||
|
header: {
|
||||||
|
title: 'Analytics Dashboard',
|
||||||
|
activeUsers: 'aktive Nutzer'
|
||||||
|
},
|
||||||
|
// Metrikkarten
|
||||||
|
metrics: {
|
||||||
|
pageViews: {
|
||||||
|
title: 'Seitenaufrufe',
|
||||||
|
perMinute: 'pro Minute'
|
||||||
|
},
|
||||||
|
uniqueVisitors: {
|
||||||
|
title: 'Eindeutige Besucher',
|
||||||
|
currentlyActive: 'aktuell aktiv'
|
||||||
|
},
|
||||||
|
conversions: {
|
||||||
|
title: 'Konversionen',
|
||||||
|
rate: 'Konversionsrate'
|
||||||
|
},
|
||||||
|
timeOnSite: {
|
||||||
|
title: 'Durchschn. Besuchszeit',
|
||||||
|
bounceRate: 'Absprungrate'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Top-Seiten Bereich
|
||||||
|
topPages: {
|
||||||
|
title: 'Top-Seiten',
|
||||||
|
columns: {
|
||||||
|
path: 'Pfad',
|
||||||
|
views: 'Aufrufe',
|
||||||
|
change: 'Änderung'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Event-Tracking Bereich
|
||||||
|
events: {
|
||||||
|
title: 'Event-Tracking',
|
||||||
|
types: {
|
||||||
|
contactForm: 'Kontaktformular Absendung',
|
||||||
|
portfolioView: 'Portfolio Ansicht',
|
||||||
|
cvDownload: 'Lebenslauf Download',
|
||||||
|
blogRead: 'Blog Gelesen'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Statusmeldungen
|
||||||
|
status: {
|
||||||
|
loading: 'Lade Dashboard...',
|
||||||
|
error: 'Fehler beim Laden der Daten',
|
||||||
|
notAuthenticated: 'Bitte melden Sie sich an'
|
||||||
|
},
|
||||||
|
// Sonstiges
|
||||||
|
misc: {
|
||||||
|
viewMore: 'Mehr anzeigen',
|
||||||
|
refresh: 'Aktualisieren',
|
||||||
|
period: {
|
||||||
|
today: 'Heute',
|
||||||
|
yesterday: 'Gestern',
|
||||||
|
last7Days: 'Letzte 7 Tage',
|
||||||
|
last30Days: 'Letzte 30 Tage',
|
||||||
|
thisMonth: 'Dieser Monat',
|
||||||
|
lastMonth: 'Letzter Monat'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Trends und Änderungen
|
||||||
|
trends: {
|
||||||
|
increase: 'Zunahme',
|
||||||
|
decrease: 'Abnahme',
|
||||||
|
noChange: 'Keine Änderung'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/about.ts
|
||||||
|
export const about = {
|
||||||
|
title: 'Über Mich',
|
||||||
|
content: 'Als Berater im Bereich der Digitalisierung helfe ich Unternehmen, ihre Abläufe durch moderne Software zu verbessern. Ich entwickle Lösungen zur Verbindung von Warenwirtschaftssystemen, Programme zur Automatisierung von Arbeitsabläufen und RFID-Anwendungen zur besseren Warenverfolgung. Bei der Ritter Digital GmbH arbeite ich mit JTL-Software und entwickle individuelle Programmlösungen. Ein weiteres Projekt war die Verbindung der Systeme von Breuninger mit Apparel Magic über TB.One. Mein Ziel ist es, Unternehmen durch Software effizienter zu machen.',
|
||||||
|
image: {
|
||||||
|
alt: 'Portrait von Damjan Savić'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
export type AboutTranslations = typeof about;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/experience.ts
|
||||||
|
export const experience = {
|
||||||
|
title: 'Berufserfahrung',
|
||||||
|
navigation: {
|
||||||
|
prev: '←',
|
||||||
|
next: '→'
|
||||||
|
},
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
role: 'Consultant Digital Solutions & Developer',
|
||||||
|
company: 'RITTER Gesellschaft für digitale Geschäftsprozesse mbH',
|
||||||
|
period: '08/2023 - HEUTE',
|
||||||
|
highlights: [
|
||||||
|
'JTL WaWi, WMS und POS Beratung & Implementierung',
|
||||||
|
'Python-basierte Automationslösungen',
|
||||||
|
'RFID-Systeme und GUI-Entwicklung',
|
||||||
|
'KI & Business Intelligence'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'ERP-Integrationsspezialist & E-Commerce-Entwickler',
|
||||||
|
company: 'Joyce & Girls',
|
||||||
|
period: '01/2023 - 08/2023',
|
||||||
|
highlights: [
|
||||||
|
'ApparelMagic & TradeByte Integration',
|
||||||
|
'MariaDB Middleware-Entwicklung',
|
||||||
|
'Windows Server Administration',
|
||||||
|
'Shopify-Optimierung'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'RPA Developer & E-Commerce Manager',
|
||||||
|
company: 'C&S Marketing',
|
||||||
|
period: '08/2022 - 01/2023',
|
||||||
|
highlights: [
|
||||||
|
'Shopware 6 & JTL Integration',
|
||||||
|
'Python Automatisierung',
|
||||||
|
'Google Ads Optimierung',
|
||||||
|
'CMS Management'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'E-Commerce Developer',
|
||||||
|
company: 'Brands Club GmbH',
|
||||||
|
period: '08/2021 - 07/2022',
|
||||||
|
highlights: [
|
||||||
|
'Shopify & JTL Integration',
|
||||||
|
'Server Administration',
|
||||||
|
'Content Production',
|
||||||
|
'Marketing Automation'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'E-Commerce Developer',
|
||||||
|
company: 'Feine Uhren Eupen',
|
||||||
|
period: '01/2021 - 08/2021',
|
||||||
|
highlights: [
|
||||||
|
'Shopware 5 Development',
|
||||||
|
'Produktfotografie',
|
||||||
|
'Sales im Luxusuhrensegment',
|
||||||
|
'E-Commerce Management'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'Online Marketing Manager',
|
||||||
|
company: 'Ufer8, Ambis, Teatro & Die Halle Tor 2',
|
||||||
|
period: '01/2018 - 12/2020',
|
||||||
|
highlights: [
|
||||||
|
'Social Media Content',
|
||||||
|
'Event Marketing',
|
||||||
|
'Veranstaltungsorganisation',
|
||||||
|
'Marketing Automation'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
export type ExperienceTranslations = typeof experience;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/expertise.ts
|
||||||
|
export const expertise = {
|
||||||
|
title: 'Bereiche meiner Expertise',
|
||||||
|
areas: [
|
||||||
|
{
|
||||||
|
title: 'ERP Integration',
|
||||||
|
description: 'Expert für JTL-Wawi Implementierung, Konfiguration und Optimierung. Spezialisiert auf Warenwirtschaftssysteme und POS-Integration.',
|
||||||
|
skills: ['JTL-Wawi', 'JTL-WMS', 'JTL-POS', 'Apparel Magic']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Entwicklung',
|
||||||
|
description: 'Full-Stack Entwicklung mit Fokus auf Python-Automatisierung, React Frontends und Datenbankdesign.',
|
||||||
|
skills: ['Python', 'React/Next.js', 'MariaDB', 'Docker']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'E-Commerce',
|
||||||
|
description: 'Umfangreiche Erfahrung mit verschiedenen E-Commerce-Plattformen und Marktplatz-Integrationen.',
|
||||||
|
skills: ['Shopify', 'Shopware', 'WooCommerce', 'API Integration']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Digitales Marketing',
|
||||||
|
description: 'Strategischer Ansatz im digitalen Marketing mit Fokus auf Automatisierung und Analyse.',
|
||||||
|
skills: ['Google Ads', 'Meta Ads', 'SEO', 'Analytics']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
export type ExpertiseTranslations = typeof expertise;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/hero.ts
|
||||||
|
export const hero = {
|
||||||
|
title: 'SOFTWARE ENGINEER',
|
||||||
|
name: 'DAMJAN SAVIĆ',
|
||||||
|
cta: 'KONTAKT AUFNEHMEN',
|
||||||
|
image: {
|
||||||
|
alt: 'Portrait von Damjan Savić'
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
getInTouch: 'Kontaktieren Sie mich',
|
||||||
|
linkedin: {
|
||||||
|
title: 'Besuchen Sie mein LinkedIn Profil'
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
title: 'Besuchen Sie mein GitHub Profil'
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
title: 'Senden Sie mir eine E-Mail'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
experience: 'ERFAHRUNG',
|
||||||
|
skills: 'FÄHIGKEITEN',
|
||||||
|
projects: 'PROJEKTE',
|
||||||
|
about: 'ÜBER MICH'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type HeroTranslations = typeof hero;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/index.ts
|
||||||
|
import { hero } from './hero';
|
||||||
|
import { about } from './about';
|
||||||
|
import { experience } from './experience';
|
||||||
|
import { expertise } from './expertise';
|
||||||
|
import { skills } from './skills';
|
||||||
|
import { projects } from './projects';
|
||||||
|
|
||||||
|
export const home = {
|
||||||
|
hero,
|
||||||
|
about,
|
||||||
|
experience,
|
||||||
|
expertise,
|
||||||
|
skills,
|
||||||
|
projects
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type HomeTranslations = typeof home;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/projects.ts
|
||||||
|
export const projects = {
|
||||||
|
title: 'Portfolio',
|
||||||
|
viewAll: 'Alle Projekte →',
|
||||||
|
loading: 'Projekte werden geladen...',
|
||||||
|
error: {
|
||||||
|
loading: 'Fehler beim Laden der Projekte'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
export type ProjectsTranslations = typeof projects;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// src/i18n/locales/de/pages/home/skills.ts
|
||||||
|
export const skills = {
|
||||||
|
title: 'Fähigkeiten',
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: 'ERP',
|
||||||
|
level: 90,
|
||||||
|
description: 'Enterprise Resource Planning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Datenbank',
|
||||||
|
level: 85,
|
||||||
|
description: 'Datenbankentwicklung und -administration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E-Commerce',
|
||||||
|
level: 95,
|
||||||
|
description: 'Online-Handel und Plattformen'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Python',
|
||||||
|
level: 80,
|
||||||
|
description: 'Programmiersprache Python'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'React',
|
||||||
|
level: 75,
|
||||||
|
description: 'Frontend-Entwicklung mit React'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Server',
|
||||||
|
level: 70,
|
||||||
|
description: 'Server-Administration'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
aria: {
|
||||||
|
skillLevel: 'Fähigkeitsniveau',
|
||||||
|
selectSkill: 'Fähigkeit auswählen'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
export type SkillsTranslations = typeof skills;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// src/i18n/locales/de/pages/login.ts
|
||||||
|
export const login = {
|
||||||
|
// SEO
|
||||||
|
seo: {
|
||||||
|
title: 'Login',
|
||||||
|
description: 'Anmelden für Zugriff auf das Dashboard'
|
||||||
|
},
|
||||||
|
// Hauptüberschrift
|
||||||
|
header: {
|
||||||
|
title: 'Im Dashboard anmelden'
|
||||||
|
},
|
||||||
|
// Formular
|
||||||
|
form: {
|
||||||
|
// Felder
|
||||||
|
email: {
|
||||||
|
label: 'E-Mail-Adresse',
|
||||||
|
placeholder: 'ihre.email@beispiel.de'
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Passwort',
|
||||||
|
placeholder: 'Ihr Passwort'
|
||||||
|
},
|
||||||
|
// Buttons
|
||||||
|
submit: {
|
||||||
|
default: 'Anmelden',
|
||||||
|
loading: 'Anmeldung läuft...'
|
||||||
|
},
|
||||||
|
// Fehlermeldungen
|
||||||
|
errors: {
|
||||||
|
default: 'Ein Fehler ist aufgetreten',
|
||||||
|
invalidCredentials: 'Ungültige Anmeldedaten',
|
||||||
|
emailRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
|
||||||
|
passwordRequired: 'Bitte geben Sie Ihr Passwort ein',
|
||||||
|
emailInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Links und Hilfe
|
||||||
|
help: {
|
||||||
|
forgotPassword: 'Passwort vergessen?',
|
||||||
|
needHelp: 'Benötigen Sie Hilfe?',
|
||||||
|
contactSupport: 'Support kontaktieren'
|
||||||
|
},
|
||||||
|
// Statusmeldungen
|
||||||
|
status: {
|
||||||
|
authenticating: 'Authentifizierung...',
|
||||||
|
success: 'Erfolgreich angemeldet',
|
||||||
|
redirecting: 'Weiterleitung zum Dashboard...'
|
||||||
|
},
|
||||||
|
// Sicherheitshinweise
|
||||||
|
security: {
|
||||||
|
secureConnection: 'Sichere Verbindung',
|
||||||
|
privacyNote: 'Ihre Daten werden verschlüsselt übertragen'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// src/i18n/locales/de/pages/notfound.ts
|
||||||
|
export const notfound = {
|
||||||
|
// SEO
|
||||||
|
seo: {
|
||||||
|
title: '404 - Seite nicht gefunden',
|
||||||
|
description: 'Die gesuchte Seite existiert nicht.'
|
||||||
|
},
|
||||||
|
// Hauptinhalt
|
||||||
|
content: {
|
||||||
|
errorCode: '404',
|
||||||
|
title: 'Seite nicht gefunden',
|
||||||
|
description: 'Die gesuchte Seite existiert nicht oder wurde verschoben.',
|
||||||
|
},
|
||||||
|
// Aktionsbuttons
|
||||||
|
actions: {
|
||||||
|
goBack: 'Zurück',
|
||||||
|
homePage: 'Startseite'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// src/i18n/locales/de/pages/portfolio.ts
|
||||||
|
export const portfolio = {
|
||||||
|
// SEO und Meta-Informationen
|
||||||
|
seo: {
|
||||||
|
title: 'Portfolio - Damjan Savić',
|
||||||
|
description: 'Entdecken Sie meine Projekte im Bereich JTL-Integration, E-Commerce und Digitale Transformation.'
|
||||||
|
},
|
||||||
|
// Hauptseite
|
||||||
|
main: {
|
||||||
|
title: 'Portfolio',
|
||||||
|
loading: 'Projekte werden geladen...',
|
||||||
|
error: 'Fehler beim Laden der Projekte',
|
||||||
|
noProjects: {
|
||||||
|
title: 'Keine Projekte gefunden',
|
||||||
|
description: 'Aktuell sind keine Projekte verfügbar. Schauen Sie später wieder vorbei.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Projekt-Karten
|
||||||
|
projectCard: {
|
||||||
|
date: 'Datum',
|
||||||
|
technologies: 'Technologien',
|
||||||
|
moreCount: 'weitere', // für '+X weitere'
|
||||||
|
readMore: 'Mehr erfahren'
|
||||||
|
},
|
||||||
|
// Projekt-Details
|
||||||
|
project: {
|
||||||
|
navigation: {
|
||||||
|
back: 'Zurück zum Portfolio',
|
||||||
|
projectDetails: 'PROJEKT DETAILS'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
date: 'Datum',
|
||||||
|
client: 'Kunde',
|
||||||
|
duration: 'Dauer',
|
||||||
|
repository: 'Repository',
|
||||||
|
documentation: 'Dokumentation',
|
||||||
|
technologies: 'Technologien',
|
||||||
|
live: 'Live-Version'
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
overview: 'Überblick',
|
||||||
|
challenge: 'Herausforderung',
|
||||||
|
solution: 'Lösung',
|
||||||
|
results: 'Ergebnisse',
|
||||||
|
systemArchitecture: 'Systemarchitektur',
|
||||||
|
implementation: 'Implementierung',
|
||||||
|
technologies: 'Verwendete Technologien'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Kategorien und Filter
|
||||||
|
categories: {
|
||||||
|
all: 'Alle',
|
||||||
|
'Data Science': 'Data Science',
|
||||||
|
'AI Development': 'KI-Entwicklung',
|
||||||
|
'Integration': 'Integration',
|
||||||
|
'Full-Stack Development': 'Full-Stack Entwicklung',
|
||||||
|
'Data Processing': 'Datenverarbeitung',
|
||||||
|
'Content Production': 'Content-Produktion',
|
||||||
|
'Machine Learning': 'Machine Learning',
|
||||||
|
'Automation': 'Automatisierung'
|
||||||
|
},
|
||||||
|
// Spezifische Projekte
|
||||||
|
projects: {
|
||||||
|
'ai-data-reader': {
|
||||||
|
title: 'KI-basierte Produktdatenpflege aus PDF-Dateien',
|
||||||
|
description: 'Entwicklung eines automatisierten Systems zur Extraktion und Strukturierung von Produktdaten aus PDF-Dokumenten'
|
||||||
|
},
|
||||||
|
'bi-vision': {
|
||||||
|
title: 'KI-Videos für BI-Software',
|
||||||
|
description: 'Entwicklung von KI-gestützten Erklärvideos für Business Intelligence Dashboards'
|
||||||
|
},
|
||||||
|
'business-intelligence-ai': {
|
||||||
|
title: 'KI für Business Intelligence',
|
||||||
|
description: 'Integration von KI-Modellen in Business Intelligence Systeme zur Prozessoptimierung'
|
||||||
|
},
|
||||||
|
'claude-personal-assistant': {
|
||||||
|
title: 'KI-basierter Personal Assistant mit Claude AI',
|
||||||
|
description: 'Entwicklung eines personalisierten KI-Assistenten für professionelle E-Mail-Kommunikation'
|
||||||
|
},
|
||||||
|
'e-commerce-integration': {
|
||||||
|
title: 'Integration mit TradeByte',
|
||||||
|
description: 'Integration eines Shopify-Shops mit JTL-WaWi für optimierte Bestandsführung'
|
||||||
|
},
|
||||||
|
'jtl-integration-project': {
|
||||||
|
title: 'JTL Integration Referenzprojekt',
|
||||||
|
description: 'Ein vollständiges Lagersystem in Krefeld auf Basis von JTL'
|
||||||
|
},
|
||||||
|
'local-llm-training': {
|
||||||
|
title: 'Training von lokalen KI-Modellen',
|
||||||
|
description: 'Entwicklung einer Pipeline für das Training und die Feinabstimmung lokaler KI-Modelle'
|
||||||
|
},
|
||||||
|
'processautomation': {
|
||||||
|
title: 'Automatisierung mit Python',
|
||||||
|
description: 'Entwicklung von Automatisierungslösungen für Geschäftsprozesse'
|
||||||
|
},
|
||||||
|
'rfid-automation': {
|
||||||
|
title: 'RFID-Automatisierung',
|
||||||
|
description: 'Entwicklung eines automatisierten Systems zur Erstellung und zum Druck von RFID-Etiketten'
|
||||||
|
},
|
||||||
|
'timetracking-software': {
|
||||||
|
title: 'Digitale Zeiterfassung',
|
||||||
|
description: 'Entwicklung einer modernen Zeiterfassungslösung mit Next.js, Flask und MSSQL'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Status-Meldungen
|
||||||
|
status: {
|
||||||
|
loading: 'Wird geladen...',
|
||||||
|
error: 'Ein Fehler ist aufgetreten',
|
||||||
|
success: 'Erfolgreich geladen'
|
||||||
|
}
|
||||||
|
};
|
||||||