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'
|
||||
}
|
||||
};
|
||||