Compare commits
3 commits
2943ace31b
...
3aabd391c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aabd391c1 | |||
| d154d2e937 | |||
| 5e09ae35f0 |
BIN
src/assets/projects/discretizeui/demo.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
src/assets/projects/discretizeui/languages.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
src/assets/projects/discretizeui/tooltip.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
src/assets/projects/videovault/dashboard.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
src/assets/projects/videovault/edit.png
Normal file
|
After Width: | Height: | Size: 835 KiB |
BIN
src/assets/projects/videovault/frontpage.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
src/assets/projects/videovault/player.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -1,32 +1,20 @@
|
||||||
---
|
---
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
// Types & Props
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
import type { ImageMetadata } from "astro";
|
import type { ImageMetadata } from "astro";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Array of images for the carousel */
|
|
||||||
images: {
|
images: {
|
||||||
/** Astro image metadata – we only need the `src` field */
|
|
||||||
src: ImageMetadata;
|
src: ImageMetadata;
|
||||||
/** Alt text for the image (required for accessibility) */
|
|
||||||
alt: string;
|
alt: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { images } = Astro.props;
|
const { images } = Astro.props;
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
// Unique ID – needed so multiple carousels on the same page don’t
|
|
||||||
// clash when we query the DOM from the <script> block.
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
const helpId = `${carouselId}-help`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- ──────────────────────────────────────────────────────────────
|
|
||||||
Carousel markup – all styling is done with Tailwind classes.
|
|
||||||
The outer <section> gets the proper ARIA roles/labels.
|
|
||||||
────────────────────────────────────────────────────────────── -->
|
|
||||||
<section
|
<section
|
||||||
id={carouselId}
|
id={carouselId}
|
||||||
class="relative overflow-hidden rounded-lg"
|
class="relative overflow-hidden rounded-lg"
|
||||||
|
|
@ -34,21 +22,22 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
aria-roledescription="carousel"
|
aria-roledescription="carousel"
|
||||||
aria-label="Image carousel"
|
aria-label="Image carousel"
|
||||||
data-carousel-id={carouselId}
|
data-carousel-id={carouselId}
|
||||||
|
tabindex="0"
|
||||||
|
aria-describedby={helpId}
|
||||||
>
|
>
|
||||||
<!-- Slides -->
|
<!-- Slides -->
|
||||||
<div class="relative h-0 pb-[56.25%]">
|
<div class="relative h-0 pb-[56.25%]">
|
||||||
<!-- 16:9 ratio placeholder -->
|
|
||||||
{
|
{
|
||||||
images.map((image, i) => (
|
images.map((image, i) => (
|
||||||
<img
|
<img
|
||||||
src={image.src.src}
|
src={image.src.src}
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
class={`
|
class={`
|
||||||
absolute inset-0 w-full h-full object-cover
|
absolute inset-0 w-full h-full object-cover object-top
|
||||||
transition-opacity duration-500 ease-in-out
|
transition-opacity duration-500 ease-in-out
|
||||||
${i === 0 ? "opacity-100" : "opacity-0"}
|
${i === 0 ? "opacity-100" : "opacity-0"}
|
||||||
carousel-slide
|
carousel-slide
|
||||||
`}
|
`}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -67,19 +56,7 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
aria-label="Previous slide"
|
aria-label="Previous slide"
|
||||||
data-dir="prev"
|
data-dir="prev"
|
||||||
>
|
>
|
||||||
<!-- Heroicon: chevron‑left -->
|
<Icon name="mdi:chevron-left" class="h-5 w-5" />
|
||||||
<svg
|
|
||||||
class="h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Previous</span>
|
<span class="sr-only">Previous</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -94,22 +71,24 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
aria-label="Next slide"
|
aria-label="Next slide"
|
||||||
data-dir="next"
|
data-dir="next"
|
||||||
>
|
>
|
||||||
<!-- Heroicon: chevron‑right -->
|
<Icon name="mdi:chevron-right" class="h-5 w-5" />
|
||||||
<svg
|
|
||||||
class="h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Next</span>
|
<span class="sr-only">Next</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Theater toggle button (bottom-right) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute bottom-4 right-4 flex h-10 w-10 items-center justify-center
|
||||||
|
rounded-full bg-white/30 backdrop-blur-sm text-gray-800
|
||||||
|
hover:bg-white focus:outline-none focus-visible:ring-2
|
||||||
|
focus-visible:ring-indigo-600 transition-colors"
|
||||||
|
aria-label="Enter wide mode"
|
||||||
|
aria-pressed="false"
|
||||||
|
data-theater-toggle
|
||||||
|
>
|
||||||
|
<Icon name="mdi:arrow-expand-horizontal" class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Live region for screen‑reader announcements -->
|
<!-- Live region for screen‑reader announcements -->
|
||||||
<div
|
<div
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
|
|
@ -118,17 +97,15 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
data-live-announcer
|
data-live-announcer
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden helper text for screen readers -->
|
||||||
|
<p id={helpId} class="sr-only">
|
||||||
|
Carousel focused. Use Left and Right Arrow keys to change slides. Press T to
|
||||||
|
toggle wide mode. Press Escape to exit wide mode.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ──────────────────────────────────────────────────────────────
|
|
||||||
Tailwind utilities are already available in the project.
|
|
||||||
No extra CSS is required – everything lives in the classes above.
|
|
||||||
────────────────────────────────────────────────────────────── -->
|
|
||||||
|
|
||||||
<script define:vars={{ carouselId }}>
|
<script define:vars={{ carouselId }}>
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Carousel logic – runs once the component is in the DOM.
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const carousel = document.getElementById(carouselId);
|
const carousel = document.getElementById(carouselId);
|
||||||
if (!carousel) return;
|
if (!carousel) return;
|
||||||
|
|
@ -137,14 +114,49 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
const prevBtn = carousel.querySelector('[data-dir="prev"]');
|
const prevBtn = carousel.querySelector('[data-dir="prev"]');
|
||||||
const nextBtn = carousel.querySelector('[data-dir="next"]');
|
const nextBtn = carousel.querySelector('[data-dir="next"]');
|
||||||
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
|
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
|
||||||
|
const theaterBtn = carousel.querySelector("[data-theater-toggle]");
|
||||||
|
|
||||||
let current = 0;
|
let current = 0;
|
||||||
const total = slides.length;
|
const total = slides.length;
|
||||||
let autoRotateTimer;
|
let autoRotateTimer;
|
||||||
|
let theater = false;
|
||||||
|
|
||||||
|
const updateTheaterButton = () => {
|
||||||
|
if (!theaterBtn) return;
|
||||||
|
theaterBtn.setAttribute("aria-pressed", theater ? "true" : "false");
|
||||||
|
theaterBtn.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
theater ? "Exit wide mode" : "Enter wide mode",
|
||||||
|
);
|
||||||
|
const icon = theaterBtn.querySelector("svg");
|
||||||
|
if (icon) {
|
||||||
|
icon.setAttribute("data-icon-state", theater ? "collapse" : "expand");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTheaterState = () => {
|
||||||
|
const section = carousel.closest("[data-threecol]");
|
||||||
|
if (!section) return;
|
||||||
|
section.classList.toggle("theater-mode", theater);
|
||||||
|
updateTheaterButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheater = () => {
|
||||||
|
theater = !theater;
|
||||||
|
applyTheaterState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitTheater = () => {
|
||||||
|
if (!theater) return;
|
||||||
|
theater = false;
|
||||||
|
applyTheaterState();
|
||||||
|
};
|
||||||
|
|
||||||
|
theaterBtn?.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleTheater();
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Helper – show a slide by index (with fade transition)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
const showSlide = (index) => {
|
const showSlide = (index) => {
|
||||||
slides[current].classList.remove("opacity-100");
|
slides[current].classList.remove("opacity-100");
|
||||||
slides[current].classList.add("opacity-0");
|
slides[current].classList.add("opacity-0");
|
||||||
|
|
@ -154,15 +166,11 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
slides[current].classList.remove("opacity-0");
|
slides[current].classList.remove("opacity-0");
|
||||||
slides[current].classList.add("opacity-100");
|
slides[current].classList.add("opacity-100");
|
||||||
|
|
||||||
// Update screen‑reader announcement
|
|
||||||
if (liveAnnouncer) {
|
if (liveAnnouncer) {
|
||||||
liveAnnouncerContent = `Slide ${current + 1} of ${total}`;
|
liveAnnouncer.textContent = `Slide ${current + 1} of ${total}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Navigation button handlers
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
const goPrev = () => {
|
const goPrev = () => {
|
||||||
const prev = (current - 1 + total) % total;
|
const prev = (current - 1 + total) % total;
|
||||||
showSlide(prev);
|
showSlide(prev);
|
||||||
|
|
@ -175,24 +183,37 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
prevBtn?.addEventListener("click", goPrev);
|
prevBtn?.addEventListener("click", goPrev);
|
||||||
nextBtn?.addEventListener("click", goNext);
|
nextBtn?.addEventListener("click", goNext);
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// Key handling (now works when the section itself is focused)
|
||||||
// Keyboard navigation (left / right arrows)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
carousel.addEventListener("keydown", (e) => {
|
carousel.addEventListener("keydown", (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
const isTyping =
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable);
|
||||||
|
if (isTyping) return;
|
||||||
|
|
||||||
if (e.key === "ArrowLeft") {
|
if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
goPrev();
|
goPrev();
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
goNext();
|
goNext();
|
||||||
|
} else if (
|
||||||
|
(e.key === "t" || e.key === "T") &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleTheater();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
exitTheater();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Auto‑rotate (pause on hover / focus)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
const startAutoRotate = () => {
|
const startAutoRotate = () => {
|
||||||
stopAutoRotate(); // safety
|
stopAutoRotate();
|
||||||
autoRotateTimer = window.setInterval(goNext, 5000);
|
autoRotateTimer = window.setInterval(goNext, 5000);
|
||||||
};
|
};
|
||||||
const stopAutoRotate = () => {
|
const stopAutoRotate = () => {
|
||||||
|
|
@ -202,10 +223,8 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start rotating when the component mounts
|
|
||||||
startAutoRotate();
|
startAutoRotate();
|
||||||
|
|
||||||
// Pause when the user hovers or focuses inside the carousel
|
|
||||||
carousel.addEventListener("mouseenter", stopAutoRotate);
|
carousel.addEventListener("mouseenter", stopAutoRotate);
|
||||||
carousel.addEventListener("mouseleave", startAutoRotate);
|
carousel.addEventListener("mouseleave", startAutoRotate);
|
||||||
carousel.addEventListener("focusin", stopAutoRotate);
|
carousel.addEventListener("focusin", stopAutoRotate);
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,99 @@
|
||||||
---
|
---
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
import { projects } from "../consts";
|
import { type Project } from "../consts";
|
||||||
import Carousel from "./Carousel.astro";
|
import Carousel from "./Carousel.astro";
|
||||||
import ThreeColumnSection from "./ThreeColumnSection.astro";
|
import ThreeColumnSection from "./ThreeColumnSection.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projects } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="py-16 bg-mytheme-200/70 dark:bg-mytheme-700/50">
|
{
|
||||||
<h2
|
projects.map((project) => (
|
||||||
class="text-3xl md:text-4xl font-bold mb-12 text-center text-slate-800 dark:text-slate-100"
|
<ThreeColumnSection maxWidth="8xl" py="py-8">
|
||||||
>
|
{/* ---------- LEFT: Project details ---------- */}
|
||||||
Software I developed
|
<div slot="left" class="max-w-xs justify-self-center lg:justify-self-end">
|
||||||
</h2>
|
<article class="space-y-4">
|
||||||
|
{/* Title + optional company */}
|
||||||
|
<h3 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
{project.title}
|
||||||
|
|
||||||
<div class="space-y-16">
|
{/* Duration */}
|
||||||
{
|
<p class="text-sm text-slate-500 dark:text-slate-400 flex items-center mt-1">
|
||||||
projects.map((project) => (
|
<Icon name="mdi:calendar" class="mr-2" />
|
||||||
<ThreeColumnSection maxWidth="8xl" py="py-8">
|
{project.duration}
|
||||||
{/* ---------- LEFT: Project details ---------- */}
|
</p>
|
||||||
<div
|
</h3>
|
||||||
slot="left"
|
|
||||||
class="max-w-xs justify-self-center lg:justify-self-end"
|
|
||||||
>
|
|
||||||
<article class="space-y-4">
|
|
||||||
{/* Title + optional company */}
|
|
||||||
<h3 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
{project.title}
|
|
||||||
|
|
||||||
{/* Duration */}
|
{/* Description */}
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 flex items-center mt-1">
|
<p class="text-base text-slate-800 dark:text-slate-200">
|
||||||
<Icon name="mdi:calendar" class="mr-2" />
|
{project.description}
|
||||||
{project.duration}
|
</p>
|
||||||
</p>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Tech stack */}
|
||||||
<p class="text-base text-slate-800 dark:text-slate-200">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
{project.description}
|
{project.tech_stack.map((tech) => (
|
||||||
</p>
|
<span class="px-2 py-0.5 text-xs font-medium bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded">
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tech stack */}
|
{/* Deliverables (optional) */}
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
{project.deliverables?.length && (
|
||||||
{project.tech_stack.map((tech) => (
|
<ul class="list-disc list-inside text-sm text-slate-700 dark:text-slate-300 mt-3">
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded">
|
{project.deliverables.map((item) => (
|
||||||
{tech}
|
<li class="mb-1">{item}</li>
|
||||||
</span>
|
))}
|
||||||
))}
|
</ul>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Deliverables (optional) */}
|
{/* Links */}
|
||||||
{project.deliverables?.length && (
|
<div class="flex space-x-4 mt-4">
|
||||||
<ul class="list-disc list-inside text-sm text-slate-700 dark:text-slate-300 mt-3">
|
{project.live_url && (
|
||||||
{project.deliverables.map((item) => (
|
<a
|
||||||
<li class="mb-1">{item}</li>
|
href={project.live_url}
|
||||||
))}
|
target="_blank"
|
||||||
</ul>
|
rel="noopener noreferrer"
|
||||||
)}
|
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
|
||||||
|
|
||||||
{/* Links */}
|
|
||||||
<div class="flex space-x-4 mt-4">
|
|
||||||
{project.live_url && (
|
|
||||||
<a
|
|
||||||
href={project.live_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
|
|
||||||
transition-colors duration-150
|
transition-colors duration-150
|
||||||
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
|
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
|
||||||
focus-visible:outline-offset-2 focus-visible:outline-slate-500
|
focus-visible:outline-offset-2 focus-visible:outline-slate-500
|
||||||
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
|
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:aspect-ratio" /> Live
|
<Icon name="mdi:aspect-ratio" /> Live
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{project.repo_url && (
|
{project.repo_url && (
|
||||||
<a
|
<a
|
||||||
href={project.repo_url}
|
href={project.repo_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
|
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
|
||||||
transition-colors duration-150
|
transition-colors duration-150
|
||||||
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
|
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
|
||||||
focus-visible:outline-offset-2 focus-visible:outline-slate-500
|
focus-visible:outline-offset-2 focus-visible:outline-slate-500
|
||||||
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
|
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:github" /> Repo
|
<Icon name="mdi:github" /> Repo
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ---------- CENTER: Carousel ---------- */}
|
{/* ---------- CENTER: Carousel ---------- */}
|
||||||
<div slot="center" class="w-full">
|
<div slot="center" class="w-full">
|
||||||
<div class="rounded-lg overflow-hidden shadow-lg">
|
<div class="rounded-lg overflow-hidden shadow-lg">
|
||||||
<Carousel images={project.images} />
|
<Carousel images={project.images} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------- RIGHT: Empty for now ---------- */}
|
{/* ---------- RIGHT: Empty for now ---------- */}
|
||||||
<div slot="right" class="hidden lg:block" />
|
<div slot="right" class="hidden lg:block" />
|
||||||
</ThreeColumnSection>
|
</ThreeColumnSection>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,113 @@ const maxWidthClasses = {
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class={`${bgClass} ${py}`}>
|
<section class={`${bgClass} ${py}`} data-threecol>
|
||||||
<div
|
<div
|
||||||
class={`${maxWidthClasses[maxWidth]} mx-auto grid grid-cols-1 lg:grid-cols-[1fr_640px_1fr] items-center md:items-start gap-8 px-4 ${containerClass}`}
|
class={`threecol-row ${maxWidthClasses[maxWidth]} mx-auto flex flex-col lg:flex-row gap-8 px-4 ${containerClass}`}
|
||||||
>
|
>
|
||||||
<!-- Left Column (Hidden on mobile, visible on desktop) -->
|
<!-- Left Column -->
|
||||||
<div class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"}`}>
|
<div
|
||||||
|
class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"} flex-col`}
|
||||||
|
data-left
|
||||||
|
>
|
||||||
<slot name="left" />
|
<slot name="left" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center Column (Main content) -->
|
<!-- Center Column -->
|
||||||
<div class={`order-1 lg:order-2`}>
|
<div class={`order-1 lg:order-2 flex-col`} data-center>
|
||||||
<slot name="center" />
|
<slot name="center" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"}`}>
|
<div
|
||||||
|
class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"} flex-col`}
|
||||||
|
data-right
|
||||||
|
>
|
||||||
<slot name="right" />
|
<slot name="right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Base layout variables */
|
||||||
|
[data-threecol] .threecol-row {
|
||||||
|
--center-initial: 640px; /* initial center width at lg+ */
|
||||||
|
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure columns can shrink properly */
|
||||||
|
[data-threecol] [data-left],
|
||||||
|
[data-threecol] [data-center],
|
||||||
|
[data-threecol] [data-right] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large-screen flex behavior (mobile stays stacked naturally) */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-threecol] .threecol-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol] [data-left] {
|
||||||
|
max-width: 33.33%;
|
||||||
|
flex: 1 1 33.33%;
|
||||||
|
/* optional: transition flex changes if you expect subtle movement */
|
||||||
|
transition: flex 0.55s var(--transition-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol] [data-center] {
|
||||||
|
flex: 0 0 var(--center-initial);
|
||||||
|
transition:
|
||||||
|
flex-basis 0.55s var(--transition-ease),
|
||||||
|
flex-grow 0.55s var(--transition-ease),
|
||||||
|
max-width 0.55s var(--transition-ease);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol] [data-right] {
|
||||||
|
flex: 1 1 0;
|
||||||
|
transition:
|
||||||
|
flex-basis 0.55s var(--transition-ease),
|
||||||
|
flex-grow 0.55s var(--transition-ease),
|
||||||
|
opacity 0.35s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THEATER MODE (expanded center) */
|
||||||
|
[data-threecol].theater-mode [data-center] {
|
||||||
|
flex: 1 1 0; /* allow it to fill remaining space */
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol].theater-mode [data-right] {
|
||||||
|
flex: 0 0 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: subtle emphasis on the center content while expanding */
|
||||||
|
[data-threecol] .threecol-row :is([data-center]) .carousel-theater-target,
|
||||||
|
[data-threecol] .threecol-row :is([data-center]) > * {
|
||||||
|
transition:
|
||||||
|
box-shadow 0.55s var(--transition-ease),
|
||||||
|
transform 0.55s var(--transition-ease);
|
||||||
|
}
|
||||||
|
[data-threecol].theater-mode [data-center] .carousel-theater-target,
|
||||||
|
[data-threecol].theater-mode [data-center] > * {
|
||||||
|
/* Example visual polish (comment out if not desired) */
|
||||||
|
/* box-shadow: 0 6px 24px -4px rgba(0,0,0,.25); */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-threecol] [data-left],
|
||||||
|
[data-threecol] [data-center],
|
||||||
|
[data-threecol] [data-right] {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
166
src/consts.ts
|
|
@ -1,8 +1,15 @@
|
||||||
import type { ImageMetadata } from "astro";
|
import type { ImageMetadata } from "astro";
|
||||||
|
import discretizeui_demo from "./assets/projects/discretizeui/demo.png";
|
||||||
|
import discretizeui_languages from "./assets/projects/discretizeui/languages.png";
|
||||||
|
import discretizeui_tooltip from "./assets/projects/discretizeui/tooltip.png";
|
||||||
import optimizer1 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_52_AM.png";
|
import optimizer1 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_52_AM.png";
|
||||||
import optimizer2 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_53_AM.png";
|
import optimizer2 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_53_AM.png";
|
||||||
import optimizer3 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_54_AM.png";
|
import optimizer3 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_54_AM.png";
|
||||||
import optimizer4 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_55_AM.png";
|
import optimizer4 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_55_AM.png";
|
||||||
|
import videovault_dashboard from "./assets/projects/videovault/dashboard.png";
|
||||||
|
import videovault_edit from "./assets/projects/videovault/edit.png";
|
||||||
|
import videovault_frontpage from "./assets/projects/videovault/frontpage.png";
|
||||||
|
import videovault_player from "./assets/projects/videovault/player.png";
|
||||||
|
|
||||||
export const SITE_TITLE = "Alex Daichendt";
|
export const SITE_TITLE = "Alex Daichendt";
|
||||||
export const SITE_DESCRIPTION =
|
export const SITE_DESCRIPTION =
|
||||||
|
|
@ -10,6 +17,7 @@ export const SITE_DESCRIPTION =
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
title: string;
|
title: string;
|
||||||
|
featured: boolean;
|
||||||
live_url?: string;
|
live_url?: string;
|
||||||
repo_url?: string;
|
repo_url?: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -24,91 +32,59 @@ export interface Project {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const projects: Project[] = [
|
export const projects: Project[] = [
|
||||||
// {
|
|
||||||
// title: "daichendt.one (this site)",
|
|
||||||
// live_url: "https://www.youtube.com/watch?v=XfELJU1mRMg",
|
|
||||||
// repo_url: "https://github.com/AlexDaichendt/site",
|
|
||||||
// description: "Personal website and blog.",
|
|
||||||
// tech_stack: ["Astro", "Tailwind CSS"],
|
|
||||||
// duration: "2022 - Present",
|
|
||||||
// deliverables: [
|
|
||||||
// "Cloudflare Workers & KV Integration",
|
|
||||||
// "Lightweight & easy to maintain",
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Radio Player",
|
|
||||||
// live_url: "https://video.taxi/en/functions/simultaneous-interpreting/",
|
|
||||||
// description: "A radio player for the VIDEO.TAXI website. Allows users to listen to radio stations with dubbed audio with the voice of the original speaker.",
|
|
||||||
// tech_stack: ["React", "TypeScript", "Vite", "Go", "Docker"],
|
|
||||||
// duration: "2024 - Present",
|
|
||||||
// complexity: 4,
|
|
||||||
// company: "TV1 GmbH",
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
title: "VIDEO.TAXI Meetings",
|
title: "VideoVault",
|
||||||
live_url: "https://meetings.video.taxi/",
|
featured: true,
|
||||||
|
live_url: "https://videovault.shiverpeak.xyz/",
|
||||||
|
repo_url: "https://github.com/AlexDaichendt/VideoVault",
|
||||||
description:
|
description:
|
||||||
"Records, transcribes, and translates meetings (Webex, Teams, Zoom). Interfaces with the GraphQL API of VIDEO.TAXI. Architecure, development, and deployment all done by me. Greenfield project.",
|
"A private, self-hosted video vault for your personal use. Supports multiple video transcodes, search, and is blazingly fast thanks to a backend written in Rust and a zero-js (vanilla) frontend.",
|
||||||
tech_stack: [
|
tech_stack: [
|
||||||
"Svelte",
|
"Rust",
|
||||||
"TypeScript",
|
"Axum",
|
||||||
|
"sqlx",
|
||||||
|
"Tera Templates",
|
||||||
"Tailwind CSS",
|
"Tailwind CSS",
|
||||||
"Express",
|
"Node.js",
|
||||||
"GraphQL",
|
"pnpm",
|
||||||
"PostgreSQL",
|
"cargo",
|
||||||
"Docker",
|
"ffmpeg",
|
||||||
"OpenAPI",
|
|
||||||
],
|
],
|
||||||
duration: "2024 - Present",
|
duration: "2025 - Present",
|
||||||
company: "TV1 GmbH",
|
|
||||||
deliverables: [
|
deliverables: [
|
||||||
"Live Updating Dashboard",
|
"HLS Streaming of videos",
|
||||||
"Meeting Bots for Teams, Zoom and Webex",
|
"Video Scrubbing",
|
||||||
"Keycloak integration",
|
"Aspect Ratio Awareness",
|
||||||
|
"Multiple Video Transcodes",
|
||||||
|
"External Transcoding Workers",
|
||||||
|
"Responsive, lightweight design",
|
||||||
|
"User Accounts via local, LDAP or OIDC",
|
||||||
|
"Sharing with timestamps",
|
||||||
|
"Quick and simple deployment (Docker)",
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
alt: "Frontpage",
|
||||||
|
src: videovault_frontpage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: "VideoVault Dashboard",
|
||||||
|
src: videovault_dashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: "VideoVault Video Player",
|
||||||
|
src: videovault_player,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: "VideoVault Edit",
|
||||||
|
src: videovault_edit,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
images: [],
|
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// title: "Netbox PDU Plugin",
|
|
||||||
// repo_url: "https://github.com/AlexDaichendt/axians-netbox-plugin-pdu",
|
|
||||||
// description:
|
|
||||||
// "Netbox plugin to read out power usage of PDUs. Forked and maintained from the original plugin by Axians. Used in production by multiple companies.",
|
|
||||||
// tech_stack: ["Python", "Django", "Netbox"],
|
|
||||||
// duration: "2023 - Present",
|
|
||||||
// complexity: 4,
|
|
||||||
// company: "TV1 GmbH",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Latency IRQ Analyzer",
|
|
||||||
// repo_url: "https://github.com/AlexDaichendt/latency-irq-analyzer",
|
|
||||||
// description:
|
|
||||||
// "Quick uni project for overlaying latency files with IRQ data.",
|
|
||||||
// tech_stack: ["NodeJS", "Highcharts"],
|
|
||||||
// duration: "2024",
|
|
||||||
// complexity: 2,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Netbox Agent",
|
|
||||||
// description:
|
|
||||||
// "Reads out lshw, dmidecode and other data of a server and creates Netbox devices. Forked from the original project.",
|
|
||||||
// tech_stack: ["Python"],
|
|
||||||
// duration: "2023 - Present",
|
|
||||||
// complexity: 2,
|
|
||||||
// company: "TV1 GmbH",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "magewell-exporter",
|
|
||||||
// repo_url: "https://github.com/TV1-EU/magewell-exporter",
|
|
||||||
// description:
|
|
||||||
// "Prometheus exporter for Magewell AiO encoders. Allows monitoring of the capture card status and video signal. Used in production by TV1.",
|
|
||||||
// tech_stack: ["NodeJs", "Typescript"],
|
|
||||||
// duration: "2023",
|
|
||||||
// complexity: 4,
|
|
||||||
// company: "TV1 GmbH",
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
title: "Discretize: Gear Optimizer",
|
title: "Discretize: Gear Optimizer",
|
||||||
|
featured: true,
|
||||||
live_url: "https://optimizer.discretize.eu/",
|
live_url: "https://optimizer.discretize.eu/",
|
||||||
repo_url: "https://github.com/discretize/discretize-gear-optimizer",
|
repo_url: "https://github.com/discretize/discretize-gear-optimizer",
|
||||||
description:
|
description:
|
||||||
|
|
@ -140,17 +116,35 @@ export const projects: Project[] = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// title: "Discretize -- UI Library",
|
title: "@discretize/gw2-ui-new",
|
||||||
// repo_url: "https://github.com/discretize/discretize-ui",
|
featured: false,
|
||||||
// description:
|
live_url: "https://discretize.github.io/discretize-ui/gw2-ui",
|
||||||
// "A beautiful component library with tooltips for the popular MMORPG Guild Wars 2. Allows websites to look and feel like the game. Integral part of the Discretize ecosystem.",
|
repo_url: "https://github.com/discretize/discretize-ui",
|
||||||
// live_url:
|
description: `A modern, lightweight React component library for Guild Wars 2 UI elements. Used by all Discretize applications.`,
|
||||||
// "https://discretize.github.io/discretize-ui/gw2-ui/?path=/story/components-attribute--boon-duration",
|
tech_stack: ["React", "TypeScript", "CSS Modules", "Storybook"],
|
||||||
// tech_stack: ["React", "TypeScript", "Storybook"],
|
duration: "2023 – Present",
|
||||||
// duration: "2021 - Present",
|
deliverables: [
|
||||||
// complexity: 5,
|
"Refactored all components to TypeScript",
|
||||||
// },
|
"Replaced CSS-in-JS with CSS Modules",
|
||||||
|
"Better performance by caching, batching",
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
src: discretizeui_demo,
|
||||||
|
alt: "Production Demo of the component library",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
src: discretizeui_tooltip,
|
||||||
|
alt: "Tooltip component",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: discretizeui_languages,
|
||||||
|
alt: "Supports multiple languages",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// title: "Discretize -- Rewritten Website",
|
// title: "Discretize -- Rewritten Website",
|
||||||
// description:
|
// description:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,23 @@
|
||||||
export const publications = [
|
export const publications = [
|
||||||
|
{
|
||||||
|
authors: [
|
||||||
|
"Alexander Daichendt",
|
||||||
|
"Florian Wiedner",
|
||||||
|
"Jonas Andre",
|
||||||
|
"Georg Carle",
|
||||||
|
],
|
||||||
|
title:
|
||||||
|
"Applicability of Hardware-Supported Containers in Low-Latency Networking",
|
||||||
|
conference:
|
||||||
|
"20th International Conference on Network and Service Management (CNSM 2024)",
|
||||||
|
location: "Prague, Czech Republic",
|
||||||
|
date: "Oct. 2024",
|
||||||
|
links: {
|
||||||
|
pdf: "http://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_2024_cnsm.pdf",
|
||||||
|
homepage: "https://tumi8.github.io/applicability-hwsupported-containers",
|
||||||
|
bibtex: "https://www.net.in.tum.de/publications/bibtex/Wied24CNSM.bib",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
authors: [
|
authors: [
|
||||||
"Florian Wiedner",
|
"Florian Wiedner",
|
||||||
|
|
@ -20,25 +39,6 @@ export const publications = [
|
||||||
bibtex: "/publications/bibtex/WiedHelm24Container.bib",
|
bibtex: "/publications/bibtex/WiedHelm24Container.bib",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
authors: [
|
|
||||||
"Alexander Daichendt",
|
|
||||||
"Florian Wiedner",
|
|
||||||
"Jonas Andre",
|
|
||||||
"Georg Carle",
|
|
||||||
],
|
|
||||||
title:
|
|
||||||
"Applicability of Hardware-Supported Containers in Low-Latency Networking",
|
|
||||||
conference:
|
|
||||||
"20th International Conference on Network and Service Management (CNSM 2024)",
|
|
||||||
location: "Prague, Czech Republic",
|
|
||||||
date: "Oct. 2024",
|
|
||||||
links: {
|
|
||||||
pdf: "http://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_2024_cnsm.pdf",
|
|
||||||
homepage: "https://tumi8.github.io/applicability-hwsupported-containers",
|
|
||||||
bibtex: "https://www.net.in.tum.de/publications/bibtex/Wied24CNSM.bib",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
authors: [
|
authors: [
|
||||||
"Florian Wiedner",
|
"Florian Wiedner",
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import Link from "../components/Link.astro";
|
||||||
import Picture from "../components/Picture.astro";
|
import Picture from "../components/Picture.astro";
|
||||||
import ProjectSection from "../components/ProjectSection.astro";
|
import ProjectSection from "../components/ProjectSection.astro";
|
||||||
import ThreeColumnSection from "../components/ThreeColumnSection.astro";
|
import ThreeColumnSection from "../components/ThreeColumnSection.astro";
|
||||||
|
import { projects } from "../consts";
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
|
||||||
const love = [
|
const love = [
|
||||||
{
|
{
|
||||||
emoji: "🦀",
|
emoji: "🦀",
|
||||||
|
|
@ -142,5 +142,17 @@ const skills = {
|
||||||
<div slot="right"></div>
|
<div slot="right"></div>
|
||||||
</ThreeColumnSection>
|
</ThreeColumnSection>
|
||||||
|
|
||||||
<ProjectSection />
|
<section class="py-16 bg-mytheme-200/70 dark:bg-mytheme-700/50">
|
||||||
|
<h2
|
||||||
|
class="text-3xl md:text-4xl font-bold mb-12 text-center text-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
Software I developed
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-16">
|
||||||
|
<ProjectSection
|
||||||
|
projects={projects.filter((project) => project.featured)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,238 +1,13 @@
|
||||||
---
|
---
|
||||||
|
import ProjectSection from "../../components/ProjectSection.astro";
|
||||||
|
import { projects } from "../../consts";
|
||||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||||
import { Icon } from "astro-icon/components";
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
{
|
|
||||||
title: "daichendt.one (this site)",
|
|
||||||
live_url: "https://www.youtube.com/watch?v=XfELJU1mRMg",
|
|
||||||
repo_url: "https://github.com/AlexDaichendt/site",
|
|
||||||
description: "Personal website and blog.",
|
|
||||||
tech_stack: ["Astro", "Tailwind CSS"],
|
|
||||||
duration: "2022 - Present",
|
|
||||||
complexity: 4,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// title: "Radio Player",
|
|
||||||
// live_url: "https://video.taxi/en/functions/simultaneous-interpreting/",
|
|
||||||
// description: "A radio player for the VIDEO.TAXI website. Allows users to listen to radio stations with dubbed audio with the voice of the original speaker.",
|
|
||||||
// tech_stack: ["React", "TypeScript", "Vite", "Go", "Docker"],
|
|
||||||
// duration: "2024 - Present",
|
|
||||||
// complexity: 4,
|
|
||||||
// company: "TV1 GmbH",
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: "VIDEO.TAXI Meetings",
|
|
||||||
live_url: "https://meetings.video.taxi/",
|
|
||||||
description:
|
|
||||||
"Records, transcribes, and translates meetings (Webex, Teams, Zoom). Interfaces with the GraphQL API of VIDEO.TAXI. Architecure, development, and deployment all done by me. Greenfield project.",
|
|
||||||
tech_stack: [
|
|
||||||
"Svelte",
|
|
||||||
"TypeScript",
|
|
||||||
"Tailwind CSS",
|
|
||||||
"Express",
|
|
||||||
"GraphQL",
|
|
||||||
"PostgreSQL",
|
|
||||||
"Docker",
|
|
||||||
"OpenAPI",
|
|
||||||
],
|
|
||||||
complexity: 5,
|
|
||||||
duration: "2024 - Present",
|
|
||||||
company: "TV1 GmbH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Netbox PDU Plugin",
|
|
||||||
repo_url: "https://github.com/AlexDaichendt/axians-netbox-plugin-pdu",
|
|
||||||
description:
|
|
||||||
"Netbox plugin to read out power usage of PDUs. Forked and maintained from the original plugin by Axians. Used in production by multiple companies.",
|
|
||||||
tech_stack: ["Python", "Django", "Netbox"],
|
|
||||||
duration: "2023 - Present",
|
|
||||||
complexity: 4,
|
|
||||||
company: "TV1 GmbH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Latency IRQ Analyzer",
|
|
||||||
repo_url: "https://github.com/AlexDaichendt/latency-irq-analyzer",
|
|
||||||
description:
|
|
||||||
"Quick uni project for overlaying latency files with IRQ data.",
|
|
||||||
tech_stack: ["NodeJS", "Highcharts"],
|
|
||||||
duration: "2024",
|
|
||||||
complexity: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Netbox Agent",
|
|
||||||
description:
|
|
||||||
"Reads out lshw, dmidecode and other data of a server and creates Netbox devices. Forked from the original project.",
|
|
||||||
tech_stack: ["Python"],
|
|
||||||
duration: "2023 - Present",
|
|
||||||
complexity: 2,
|
|
||||||
company: "TV1 GmbH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "magewell-exporter",
|
|
||||||
repo_url: "https://github.com/TV1-EU/magewell-exporter",
|
|
||||||
description:
|
|
||||||
"Prometheus exporter for Magewell AiO encoders. Allows monitoring of the capture card status and video signal. Used in production by TV1.",
|
|
||||||
tech_stack: ["NodeJs", "Typescript"],
|
|
||||||
duration: "2023",
|
|
||||||
complexity: 4,
|
|
||||||
company: "TV1 GmbH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Discretize -- Gear Optimizer",
|
|
||||||
live_url: "https://optimizer.discretize.eu/",
|
|
||||||
repo_url: "https://github.com/discretize/discretize-gear-optimizer",
|
|
||||||
description:
|
|
||||||
"A gear optimizer for the popular MMORPG Guild Wars 2. The optimizer is used by thousands of players daily to find the best gear combinations for their characters.",
|
|
||||||
tech_stack: ["React", "Redux", "Rust", "Vite", "MaterialUI"],
|
|
||||||
duration: "2021 - Present",
|
|
||||||
complexity: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Discretize -- UI Library",
|
|
||||||
repo_url: "https://github.com/discretize/discretize-ui",
|
|
||||||
description:
|
|
||||||
"A beautiful component library with tooltips for the popular MMORPG Guild Wars 2. Allows websites to look and feel like the game. Integral part of the Discretize ecosystem.",
|
|
||||||
live_url:
|
|
||||||
"https://discretize.github.io/discretize-ui/gw2-ui/?path=/story/components-attribute--boon-duration",
|
|
||||||
tech_stack: ["React", "TypeScript", "Storybook"],
|
|
||||||
duration: "2021 - Present",
|
|
||||||
complexity: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Discretize -- Rewritten Website",
|
|
||||||
description:
|
|
||||||
"Rewritten website for the Discretize community. Contains guides, builds, and other useful information for the popular MMORPG Guild Wars 2. Awaiting last few changes and content updates by players before deployment.",
|
|
||||||
live_url: "https://next.discretize.eu/",
|
|
||||||
repo_url: "https://github.com/discretize/discretize.eu-rewrite",
|
|
||||||
tech_stack: ["Astro", "React", "TypeScript", "Tailwind CSS"],
|
|
||||||
duration: "2022 - Present",
|
|
||||||
complexity: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Discretize -- CC Tool",
|
|
||||||
description:
|
|
||||||
"Allows players to create skill schedules with drag and drop. Used by high-end players to optimize and coordinate their gameplay.",
|
|
||||||
live_url: "https://cc-tool.pages.dev/",
|
|
||||||
repo_url: "https://github.com/discretize/cc-tool",
|
|
||||||
tech_stack: ["Vite", "React", "TypeScript", "Tailwind CSS"],
|
|
||||||
duration: "2024 - Present",
|
|
||||||
complexity: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Discretize -- Random Builds",
|
|
||||||
description:
|
|
||||||
"Generates random builds for the popular MMORPG Guild Wars 2. Meant as a way to force players out of their comfort zone and try new things.",
|
|
||||||
live_url: "https://random-builds.discretize.eu/",
|
|
||||||
tech_stack: ["Vite", "React", "TypeScript", "Tailwind CSS"],
|
|
||||||
duration: "2022",
|
|
||||||
complexity: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Discretize -- Old Website",
|
|
||||||
description:
|
|
||||||
"Currently deployed website for the Discretize community. Contains guides, builds, and other useful information for the popular MMORPG Guild Wars 2. Inherited project from previous maintainer. Several hundred thousand monthly users.",
|
|
||||||
live_url: "https://discretize.eu/",
|
|
||||||
tech_stack: ["React", "Gatsby", "Material UI"],
|
|
||||||
duration: "2019 - Present",
|
|
||||||
complexity: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Minecraft LandLord Spigot Plugin",
|
|
||||||
live_url: "https://www.spigotmc.org/resources/landlord-2.44398/",
|
|
||||||
repo_url: "https://github.com/LandlordPlugin/LandLord",
|
|
||||||
description:
|
|
||||||
"Landlord aims to keep the Minecraft experience simple and fluid for players while also protecting their land. The idea for this plugin is to protect player builds with minimal game-play interference, while also allowing them to tweak the protection details in a simple and user-friendly way. Handed over the project to a new group of maintainers in 2019.",
|
|
||||||
tech_stack: ["Java"],
|
|
||||||
duration: "2017 - 2019",
|
|
||||||
complexity: 2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getCardStyle = (company?: string) => {
|
|
||||||
const baseStyles =
|
|
||||||
"rounded-lg shadow-lg p-6 transition-colors duration-300 mb-8";
|
|
||||||
|
|
||||||
if (!company) {
|
|
||||||
return `${baseStyles} bg-white dark:bg-gray-800`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyColors: Record<string, string> = {
|
|
||||||
Discretize: "bg-blue-50 dark:bg-blue-900/30",
|
|
||||||
"TV1 GmbH": "border-2 border-orange-500 dark:border-orange-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${baseStyles} ${companyColors[company] || "bg-white dark:bg-gray-800"}`;
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Projects" subtitle="Selected Passion Projects I am proud of">
|
<BaseLayout
|
||||||
<p>
|
title="Projects"
|
||||||
Here are some of the projects I have worked on in the past. They are sorted
|
subtitle="Selected Passion Projects I am proud of"
|
||||||
by my personal rating of relevancy. Projects done for a company are marked
|
className="w-full py-16 flex flex-col"
|
||||||
with the company name and have a special border color.
|
>
|
||||||
</p>
|
<ProjectSection projects={projects} />
|
||||||
{
|
|
||||||
projects
|
|
||||||
.sort((a, b) => b.complexity - a.complexity)
|
|
||||||
.map((project) => (
|
|
||||||
<article class={getCardStyle(project.company)}>
|
|
||||||
<div class="flex justify-between items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
||||||
{project.title}
|
|
||||||
</h2>
|
|
||||||
{project?.company && (
|
|
||||||
<div class="flex items-center mt-1">
|
|
||||||
<Icon name="mdi:office-building" class="w-5 h-5 mr-1" />
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300 font-medium">
|
|
||||||
{project?.company}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{project.duration}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
{project.tech_stack.map((tech) => (
|
|
||||||
<span class="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full">
|
|
||||||
{tech}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4">
|
|
||||||
{project.live_url && (
|
|
||||||
<a
|
|
||||||
href={project.live_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-blue-600 dark:text-blue-400 hover:underline flex items-center"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:web" class="w-5 h-5 mr-1" />
|
|
||||||
Live Demo
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.repo_url && (
|
|
||||||
<a
|
|
||||||
href={project.repo_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-gray-600 dark:text-gray-400 hover:underline flex items-center"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:github" class="w-5 h-5 mr-1" />
|
|
||||||
Repository
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
|
||||||