initial commit

This commit is contained in:
2025-12-17 10:42:20 -05:00
commit 21564f54e0
36 changed files with 9657 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.gitignore
.env*.local
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+42
View File
@@ -0,0 +1,42 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+160
View File
@@ -0,0 +1,160 @@
# Portfolio Website
A modern, minimalist portfolio built with Next.js, TypeScript, and shadcn/ui. Features a clean dark theme design with static site generation for optimal performance.
## Tech Stack
- **Framework:** Next.js 16.0.10
- **Language:** TypeScript 5.9.3
- **UI Library:** React 19.2.1
- **Component Library:** shadcn/ui
- **Styling:** Tailwind CSS 4.1.18
- **Content:** Markdown files with gray-matter and react-markdown
## Getting Started
### Development
Run the development server:
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) to view the site.
### Build
Create an optimized production build:
```bash
npm run build
```
### Production
Start the production server:
```bash
npm start
```
## Project Structure
```
├── app/ # Next.js app directory
│ ├── page.tsx # Landing page
│ ├── about/ # About page
│ ├── blog/ # Blog pages
│ │ ├── page.tsx # Blog listing
│ │ └── [slug]/ # Individual blog posts
│ └── projects/ # Projects page
├── components/ # React components
│ ├── navigation.tsx # Site navigation
│ ├── blog-card.tsx # Blog post card
│ └── project-card.tsx # Project card
├── content/ # Markdown content
│ ├── blog/ # Blog post markdown files
│ └── projects/ # Project markdown files
└── lib/ # Utility functions
└── content.ts # Content parsing utilities
```
## Adding Content
### Blog Posts
Create a new markdown file in `content/blog/`:
```markdown
---
title: 'Your Post Title'
date: '2025-12-12'
excerpt: 'A brief summary of your post'
---
Your post content here...
```
### Projects
Create a new markdown file in `content/projects/`:
```markdown
---
title: 'Your Project'
description: 'A brief description'
links:
github: 'https://github.com/username/repo'
live: 'https://example.com'
gitea: 'https://gitea.example.com/username/repo'
---
Detailed project description...
```
## Customization
### Personal Information
Update the following files with your information:
- `app/page.tsx` - Update the welcome message and name
- `app/about/page.tsx` - Update bio, photo, and social links
- `app/layout.tsx` - Update site metadata
### Styling
The site uses a dark theme by default. To customize colors, edit `app/globals.css` and modify the CSS variables in the `.dark` class.
## Dependencies
All dependencies are pinned to exact versions for reproducible builds. To update dependencies, modify `package.json` and run `npm install`.
## Docker Deployment
This project includes Docker support for easy deployment with Traefik.
### Prerequisites
- Docker and Docker Compose installed
- Traefik running in your environment
- A `traefik` external network created
### Setup
1. Update the domain in `compose.yaml`:
```yaml
- "traefik.http.routers.portfolio.rule=Host(`your-domain.com`)"
- "traefik.http.routers.portfolio-secure.rule=Host(`your-domain.com`)"
```
2. Build and start the container:
```bash
docker compose up -d
```
3. View logs:
```bash
docker compose logs -f
```
### Traefik Configuration
The compose file includes:
- Automatic HTTP to HTTPS redirect
- Let's Encrypt SSL certificate generation
- Load balancer configuration for port 3000
- Dockge labels for better organization
### Using with Dockge
Simply import the `compose.yaml` file into Dockge, or place this project directory in your Dockge stacks folder. The service will appear with the metadata labels for easy identification.
## License
This project is open source and available under the MIT License.
+133
View File
@@ -0,0 +1,133 @@
import { Navigation } from "@/components/navigation";
import { Github, Linkedin, Mail, Code } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description: "Learn more about me and my work",
};
export default function AboutPage() {
return (
<div className="min-h-screen">
<Navigation />
<main className="container mx-auto px-6 py-16 max-w-4xl">
<h1 className="text-4xl font-bold text-foreground mb-12">About Me</h1>
<div className="grid md:grid-cols-[1fr_auto] gap-12 mb-12">
<div className="space-y-6">
<p className="text-lg text-muted-foreground leading-relaxed">
I'm Alex, a full-stack engineer at Whisker with 4 years of
experience building scalable cloud applications and IoT solutions.
My journey in software development has been driven by curiosity
and a passion for solving complex problems with efficient
solutions. I specialize in creating performant applications using
the latest technologies, with a particular interest in performance
at scale and building developer-friendly tools.
</p>
<p className="text-lg text-muted-foreground leading-relaxed">
Learning and sharing new tech is something I'm genuinely
passionate about. When I'm not coding, you'll find me contributing
to open-source projects, writing blog posts, or giving technical
talks. Off-duty, I'm usually reading, exploring the outdoors with
my dogs, or rolling dice as a D&amp;D sorcerer. This site is where
I share book thoughts, code experiments, and random shower
thoughts. Thanks for stopping by!
</p>
</div>
<div className="flex md:flex-col gap-6 items-center md:items-start">
<div className="relative w-80 h-80 rounded-lg overflow-hidden bg-muted">
<img
src="/self.webp"
alt="Alex"
className="w-full h-full object-cover"
/>
</div>
<div className="relative w-80 h-80 rounded-lg overflow-hidden bg-muted">
<img
src="/homies.jpg"
alt="Err"
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
<div className="border-t border-border/40 pt-12">
<h2 className="text-2xl font-bold text-foreground mb-8">
Connect With Me
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
href="https://github.com/amuszyn"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-6 rounded-lg border border-border/40 bg-card hover:border-border hover:shadow-lg transition-all group"
>
<Github
size={28}
className="text-muted-foreground group-hover:text-foreground transition-colors"
/>
<div>
<div className="font-semibold text-foreground">GitHub</div>
<div className="text-sm text-muted-foreground">@amuszyn</div>
</div>
</a>
<a
href="https://linkedin.com/in/almuszynski"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-6 rounded-lg border border-border/40 bg-card hover:border-border hover:shadow-lg transition-all group"
>
<Linkedin
size={28}
className="text-muted-foreground group-hover:text-foreground transition-colors"
/>
<div>
<div className="font-semibold text-foreground">LinkedIn</div>
<div className="text-sm text-muted-foreground">
Alex Muszynski
</div>
</div>
</a>
<a
href="mailto:alexander.muszynski@gmail.com"
className="flex items-center gap-4 p-6 rounded-lg border border-border/40 bg-card hover:border-border hover:shadow-lg transition-all group"
>
<Mail
size={28}
className="text-muted-foreground group-hover:text-foreground transition-colors"
/>
<div>
<div className="font-semibold text-foreground">Email</div>
<div className="text-sm text-muted-foreground">
alexander.muszynski@gmail.com
</div>
</div>
</a>
<a
href="https://gitea.muszyn.dev/muszyn"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-6 rounded-lg border border-border/40 bg-card hover:border-border hover:shadow-lg transition-all group"
>
<Code
size={28}
className="text-muted-foreground group-hover:text-foreground transition-colors"
/>
<div>
<div className="font-semibold text-foreground">Gitea</div>
<div className="text-sm text-muted-foreground">@muszyn</div>
</div>
</a>
</div>
</div>
</main>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Navigation } from "@/components/navigation";
import { getBlogPosts, getBlogPost } from "@/lib/content";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const posts = getBlogPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export async function generateMetadata({
params,
}: BlogPostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) {
return {
title: "Post Not Found",
};
}
return {
title: `${post.title}`,
description: post.excerpt,
};
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) {
notFound();
}
return (
<div className="min-h-screen">
<Navigation />
<main className="container mx-auto px-6 py-16 max-w-3xl">
<article>
<header className="mb-10">
<time className="text-sm text-muted-foreground">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<h1 className="text-4xl font-bold text-foreground mt-3 mb-4">
{post.title}
</h1>
<p className="text-lg text-muted-foreground">{post.excerpt}</p>
</header>
<div className="prose prose-invert prose-lg max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{post.content}
</ReactMarkdown>
</div>
</article>
</main>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Navigation } from "@/components/navigation";
import { BlogCard } from "@/components/blog-card";
import { getBlogPosts } from "@/lib/content";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog",
description: "Read my latest blog posts about web development",
};
export default function BlogPage() {
const posts = getBlogPosts();
return (
<div className="min-h-screen">
<Navigation />
<main className="container mx-auto px-6 py-16 max-w-4xl">
<div className="mb-16">
<h1 className="text-4xl font-bold text-foreground mb-4">Blog</h1>
<p className="text-lg text-muted-foreground">
Thoughts, tutorials, and insights about web development
</p>
</div>
<div className="grid gap-12">
{posts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
</div>
</main>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+185
View File
@@ -0,0 +1,185 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.prose {
@apply text-foreground;
max-width: 65ch;
}
.prose h1 {
@apply text-4xl font-bold text-foreground mt-8 mb-4;
}
.prose h2 {
@apply text-3xl font-bold text-foreground mt-8 mb-4;
}
.prose h3 {
@apply text-2xl font-semibold text-foreground mt-6 mb-3;
}
.prose p {
@apply text-base text-muted-foreground leading-relaxed mb-6;
}
.prose a {
@apply text-primary hover:underline;
}
.prose ul,
.prose ol {
@apply text-muted-foreground mb-6 ml-6;
}
.prose li {
@apply mb-2;
}
.prose code {
@apply bg-muted px-2 py-1 rounded text-sm font-mono;
}
.prose pre {
@apply bg-muted p-4 rounded-lg overflow-x-auto mb-6;
}
.prose pre code {
@apply bg-transparent p-0;
}
.prose blockquote {
@apply border-l-4 border-border pl-4 italic text-muted-foreground my-6;
}
.prose img {
@apply rounded-lg my-6;
}
.prose strong {
@apply font-semibold text-foreground;
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Alex Muszynski",
description: "Personal portfolio and blog",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { Navigation } from "@/components/navigation";
import { BlogCard } from "@/components/blog-card";
import { ProjectCard } from "@/components/project-card";
import { getBlogPosts, getProjects } from "@/lib/content";
import { ArrowRight } from "lucide-react";
export default function Home() {
const allPosts = getBlogPosts();
const recentPosts = allPosts.slice(0, 3);
const allProjects = getProjects();
const recentProjects = allProjects.slice(0, 3);
return (
<div className="min-h-screen">
<Navigation />
<main className="container mx-auto px-6 py-16 max-w-5xl">
<section className="mb-24">
<h1 className="text-5xl font-bold text-foreground mb-6">
Hi, I'm <span className="text-primary">Alex Muszynski</span>.
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl leading-relaxed">
I'm a tinkerer that works in the realm of software. Welcome to my
portfolio where I share my projects and thoughts on tech.
</p>
<Link
href="/about"
className="inline-flex items-center gap-2 text-lg font-medium text-foreground hover:text-primary transition-colors"
>
Learn more about me
<ArrowRight size={20} />
</Link>
</section>
<section className="mb-24">
<div className="flex items-center justify-between mb-10">
<h2 className="text-3xl font-bold text-foreground">Recent Posts</h2>
<Link
href="/blog"
className="text-base text-muted-foreground hover:text-foreground transition-colors"
>
View all posts
</Link>
</div>
<div className="grid gap-10">
{recentPosts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
</div>
</section>
<section>
<div className="flex items-center justify-between mb-10">
<h2 className="text-3xl font-bold text-foreground">
Recent Projects
</h2>
<Link
href="/projects"
className="text-base text-muted-foreground hover:text-foreground transition-colors"
>
View all projects
</Link>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{recentProjects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
</section>
</main>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Navigation } from "@/components/navigation";
import { ProjectCard } from "@/components/project-card";
import { getProjects } from "@/lib/content";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Projects",
description: "A collection of my projects and side work",
};
export default function ProjectsPage() {
const projects = getProjects();
return (
<div className="min-h-screen">
<Navigation />
<main className="container mx-auto px-6 py-16 max-w-6xl">
<div className="mb-16">
<h1 className="text-4xl font-bold text-foreground mb-4">Projects</h1>
<p className="text-lg text-muted-foreground">
A collection of projects I've built and contributed to
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
</main>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+31
View File
@@ -0,0 +1,31 @@
import Link from 'next/link';
import { BlogPost } from '@/lib/content';
interface BlogCardProps {
post: BlogPost;
}
export function BlogCard({ post }: BlogCardProps) {
return (
<Link
href={`/blog/${post.slug}`}
className="block group"
>
<article className="space-y-3">
<time className="text-sm text-muted-foreground">
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<h3 className="text-xl font-semibold text-foreground group-hover:text-primary transition-colors">
{post.title}
</h3>
<p className="text-base text-muted-foreground line-clamp-2">
{post.excerpt}
</p>
</article>
</Link>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
export function Navigation() {
return (
<nav className="w-full border-b border-border/40">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="text-xl font-semibold text-foreground hover:text-foreground/80 transition-colors"
>
Muszyn
</Link>
<div className="flex gap-8">
<Link
href="/blog"
className="text-base text-muted-foreground hover:text-foreground transition-colors"
>
Blog
</Link>
<Link
href="/projects"
className="text-base text-muted-foreground hover:text-foreground transition-colors"
>
Projects
</Link>
<Link
href="/about"
className="text-base text-muted-foreground hover:text-foreground transition-colors"
>
About
</Link>
</div>
</div>
</div>
</nav>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from 'next/link';
import { Project } from '@/lib/content';
import { Github, ExternalLink, Code } from 'lucide-react';
interface ProjectCardProps {
project: Project;
}
export function ProjectCard({ project }: ProjectCardProps) {
return (
<article className="group rounded-lg border border-border/40 bg-card p-6 transition-all hover:border-border hover:shadow-lg">
<h3 className="text-2xl font-semibold text-foreground mb-3">
{project.title}
</h3>
<p className="text-base text-muted-foreground mb-4">
{project.description}
</p>
<div className="flex gap-4 mt-4">
{project.links?.github && (
<a
href={project.links.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Github size={16} />
GitHub
</a>
)}
{project.links?.gitea && (
<a
href={project.links.gitea}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Code size={16} />
Gitea
</a>
)}
{project.links?.live && (
<a
href={project.links.live}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink size={16} />
Live Demo
</a>
)}
</div>
</article>
);
}
+69
View File
@@ -0,0 +1,69 @@
# Advanced compose.yaml with additional options
# Rename to compose.yaml to use this configuration
services:
portfolio:
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
restart: unless-stopped
# Environment variables
environment:
- NODE_ENV=production
# Add any custom Next.js environment variables here
# - NEXT_PUBLIC_SITE_URL=https://portfolio.yourdomain.com
# Health check
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- traefik
labels:
# Enable Traefik
- "traefik.enable=true"
# HTTP Router
- "traefik.http.routers.portfolio.rule=Host(`portfolio.yourdomain.com`)"
- "traefik.http.routers.portfolio.entrypoints=web"
# HTTPS Router
- "traefik.http.routers.portfolio-secure.rule=Host(`portfolio.yourdomain.com`)"
- "traefik.http.routers.portfolio-secure.entrypoints=websecure"
- "traefik.http.routers.portfolio-secure.tls=true"
- "traefik.http.routers.portfolio-secure.tls.certresolver=letsencrypt"
# HTTP to HTTPS Redirect
- "traefik.http.routers.portfolio.middlewares=portfolio-redirect"
- "traefik.http.middlewares.portfolio-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.portfolio-redirect.redirectscheme.permanent=true"
# Optional: Security Headers
- "traefik.http.routers.portfolio-secure.middlewares=portfolio-headers"
- "traefik.http.middlewares.portfolio-headers.headers.customFrameOptionsValue=SAMEORIGIN"
- "traefik.http.middlewares.portfolio-headers.headers.customResponseHeaders.X-Robots-Tag=index,follow"
- "traefik.http.middlewares.portfolio-headers.headers.sslRedirect=true"
- "traefik.http.middlewares.portfolio-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.portfolio-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.portfolio-headers.headers.stsPreload=true"
# Service
- "traefik.http.services.portfolio.loadbalancer.server.port=3000"
# Dockge metadata
- "com.dockge.project=portfolio"
- "com.dockge.description=Personal portfolio website"
# Watchtower auto-update (optional, if you use Watchtower)
- "com.centurylinklabs.watchtower.enable=true"
networks:
traefik:
external: true
+38
View File
@@ -0,0 +1,38 @@
services:
portfolio:
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
restart: unless-stopped
networks:
- traefik
labels:
# Enable Traefik
- "traefik.enable=true"
# HTTP Router
- "traefik.http.routers.portfolio.rule=Host(`portfolio.yourdomain.com`)"
- "traefik.http.routers.portfolio.entrypoints=web"
# HTTPS Router
- "traefik.http.routers.portfolio-secure.rule=Host(`portfolio.yourdomain.com`)"
- "traefik.http.routers.portfolio-secure.entrypoints=websecure"
- "traefik.http.routers.portfolio-secure.tls=true"
- "traefik.http.routers.portfolio-secure.tls.certresolver=letsencrypt"
# HTTP to HTTPS Redirect
- "traefik.http.routers.portfolio.middlewares=portfolio-redirect"
- "traefik.http.middlewares.portfolio-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.portfolio-redirect.redirectscheme.permanent=true"
# Service
- "traefik.http.services.portfolio.loadbalancer.server.port=3000"
# Dockge metadata (optional, for better organization in Dockge)
- "com.dockge.project=portfolio"
- "com.dockge.description=Personal portfolio website"
networks:
traefik:
external: true
+115
View File
@@ -0,0 +1,115 @@
---
title: 'Bluetooth on Linux'
date: '2025-12-08'
excerpt: 'Learn how to use bluetooth on a debian based linux distribution.'
---
So I've been on quite the journey learning linux as my daily driver operating system.
Today I wanted to pair my bluetooth keyboard to my desktop because.. why not, I like swapping between a few keyboards sometimes.
I went to the panel/app bar and saw no trace of a 'bluetooth' icon I had come to expect during my days on Windows and MacOS.
I attempted to look for a 'bluetooth' app to no avail and then finally went to the internet.
It didn't take long to find that my flavor of linux (mint) has a built in `bluetoothctl` tool that we can use.
## bluetoothctl
There are two ways to use `bluetoothctl` the first is by prepending every command with `bluetoothctl`. This is how the tutorial ran me through it but I wasn't a big fan and opted for the alternative way.
If you just type `bluetoothctl` you will enter an interactive utility.
```
muszyn:~/$ bluetoothctl
[Keychron K6 Pro]# Agent registered
[Keychron K6 Pro]# [CHG] Controller B4:6B:FC:6A:DB:27 Pairable: yes
```
> You may notice that I have a bluetooth device already registered `Keychron K6 Pro`, this is the keyboard I am currently using.
Once you are in the control menu there are a list of commands to easily get started:
> you can type `help` at any time to print a list of all available commands.
- scan on | scan off
- pair `{device-mac-addr}`
- connect `{device-mac-addr}`
- devices
- remove `{device-mac-addr}`
- disconnect `{device-mac-addr}`
So lets put these commands to work.
## Scanning for Devices
To search for a bluetooth device that you can connect to use the scan command.
```bash
scan on
```
This will begin discovery and output the devices that your system detects.
```
[Keychron K6 Pro]# scan on
[Keychron K6 Pro]# SetDiscoveryFilter success
[Keychron K6 Pro]# Discovery started
[Keychron K6 Pro]# [CHG] Controller B4:6B:FC:6A:DB:27 Discovering: yes
[Keychron K6 Pro]# [NEW] Device 55:41:D0:21:FD:EE 55-41-D0-21-FD-EE
[Keychron K6 Pro]# [NEW] Device 04:D5:83:26:88:0A KM18
```
The output above has a good deal of information so lets break it down.
Bluetooth devices are labeled as **Device** follwed by their Media Access Control (MAC) addresses which is a unique identifier for the device. All MAC addresses follow the format **XX:XX:XX:XX:XX:XX**. If the device has a name associated with it like *KM18*, bluetoothctl will display it. However, not every device has a display name as shown above by *55-41-D0-21-FD-EE* which is just the MAC address with '-' instead of ':'.
## Connecting to a Device
Once we have found a list of available devices we can choose to connect to one.
The first step is to pair your system to the device using the `pair` command.
Using KM18 in the list above:
```bash
pair 04:D5:83:26:88:0A
```
> Note: when using the interactive utility you can press `TAB` twice to ouput the available MAC addresses, begin typing one and then press `TAB` once to auto-fill the rest of the address.
If this isn't your first time pairing to the device then you can use the `connect` command to establish a bluetooth connection.
This is useful if your device does not automatically connect on startup or if you would like to take over the connection from another device (this happens all the time with my headphones).
```bash
connect 04:D5:83:26:88:0A
```
## Listing Paired Devices
Using the utility listing paried devices can be useful if you don't want to `scan` all the time.
```bash
devices Paried
```
This will output a list of your paired devices that you can then use to connect/disconnect from your system.
## Disconnecting Devices
To unpair the device use the `remove` command.
```bash
remove 04:D5:83:26:88:0A
```
To disconnect the device use the `disconnect` command:
```bash
disconnect 04:D5:83:26:88:0A
```
## Exiting the Interactive Mode
To exit, either type `exit`, or `quit`, OR you can use `ctl+D` whichever you prefer.
## Non-Interactive
If you want to use the non-interactive tool then simply place `bluetoothctl` in front of each command.
The primary benefit I found for the interactive tool was the tab completion for the MAC addresses :D.
+34
View File
@@ -0,0 +1,34 @@
---
title: 'Homelab Server'
description: 'A personal server that replicates a plethora of self-hosted solutions'
links:
github: 'https://github.com/amuszyn/homelab'
---
# Homelab Server
A personal server that replicates a plethora of self-hosted solutions.
## Features
- Git server
- Reverse proxy
- Log aggregation and filtering
- Monitoring and visualization
- Network wide ad-blocking
- VPN
- Automatic photo backup
- Network Security
## Built With
- Dockge
- Docker
- Graphana
- Prometheus
- Traefik
- Gitea
- Wireguard
- PiHole
- Cloudflare
- Immich
+27
View File
@@ -0,0 +1,27 @@
---
title: 'Portfolio Website'
description: 'A modern portfolio built with Next.js, TypeScript, and shadcn/ui'
links:
gitea: 'https://gitea.muszyn.dev/muszyn/portfolio'
live: 'https://site.muszyn.dev'
---
# Portfolio Website
A fully responsive portfolio website built with the latest web technologies. Features a clean, minimalist design with dark mode support.
## Technologies Used
- Next.js 16
- TypeScript
- shadcn/ui
- Tailwind CSS
- Markdown for content management
## Features
- Blog with markdown support
- Project showcase
- Fully responsive design
- Dark mode
- Fast static site generation
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+121
View File
@@ -0,0 +1,121 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const contentDirectory = path.join(process.cwd(), 'content');
export interface BlogPost {
slug: string;
title: string;
date: string;
excerpt: string;
content: string;
}
export interface Project {
slug: string;
title: string;
description: string;
links?: {
github?: string;
live?: string;
gitea?: string;
};
content: string;
}
export function getBlogPosts(): BlogPost[] {
const blogDirectory = path.join(contentDirectory, 'blog');
if (!fs.existsSync(blogDirectory)) {
return [];
}
const fileNames = fs.readdirSync(blogDirectory);
const allPosts = fileNames
.filter((fileName) => fileName.endsWith('.md'))
.map((fileName) => {
const slug = fileName.replace(/\.md$/, '');
const fullPath = path.join(blogDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
title: data.title || '',
date: data.date || '',
excerpt: data.excerpt || '',
content,
};
});
return allPosts.sort((a, b) => (a.date > b.date ? -1 : 1));
}
export function getBlogPost(slug: string): BlogPost | null {
const blogDirectory = path.join(contentDirectory, 'blog');
const fullPath = path.join(blogDirectory, `${slug}.md`);
if (!fs.existsSync(fullPath)) {
return null;
}
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
title: data.title || '',
date: data.date || '',
excerpt: data.excerpt || '',
content,
};
}
export function getProjects(): Project[] {
const projectsDirectory = path.join(contentDirectory, 'projects');
if (!fs.existsSync(projectsDirectory)) {
return [];
}
const fileNames = fs.readdirSync(projectsDirectory);
const allProjects = fileNames
.filter((fileName) => fileName.endsWith('.md'))
.map((fileName) => {
const slug = fileName.replace(/\.md$/, '');
const fullPath = path.join(projectsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
title: data.title || '',
description: data.description || '',
links: data.links,
content,
};
});
return allProjects;
}
export function getProject(slug: string): Project | null {
const projectsDirectory = path.join(contentDirectory, 'projects');
const fullPath = path.join(projectsDirectory, `${slug}.md`);
if (!fs.existsSync(fullPath)) {
return null;
}
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
title: data.title || '',
description: data.description || '',
links: data.links,
content,
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
output: 'standalone',
};
export default nextConfig;
+8177
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"gray-matter": "4.0.3",
"lucide-react": "0.561.0",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@types/node": "20.19.26",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-next": "16.0.10",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}