Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
OPENROUTER_API_KEY=your_openrouter_api_key_here
AI_MODEL=google/gemini-3.1-flash-lite

# Local development (SQLite file at project root)
DATABASE_URL="file:./dev.db"

# Production: generate with htpasswd -nB admin | sed 's/\$/\$\$/g'
# Each $ in the bcrypt hash must be written as $$ for docker-compose interpolation
ADMIN_BASIC_AUTH=admin:$$2y$$05$$...
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ next-env.d.ts

.env
.env.*
!.env.example
!.env.example
/lib/generated/prisma

# SQLite database
*.db
*.db-shm
*.db-wal
35 changes: 29 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
FROM node:22

# ---- builder ----
FROM node:22-slim AS builder
WORKDIR /app

COPY package*.json ./

RUN npm install
RUN npm ci

COPY . .

RUN npm run build
RUN DATABASE_URL="file:./dev.db" npx prisma generate
RUN DATABASE_URL="file:./dev.db" npm run build

# ---- runner ----
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

# Next.js standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# Prisma client (WASM) + schema + migrations + config
COPY --from=builder /app/lib/generated ./lib/generated
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts

# Full node_modules for prisma CLI and runtime deps
COPY --from=builder /app/node_modules ./node_modules

COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

CMD ["npm", "start"]
ENTRYPOINT ["./docker-entrypoint.sh"]
85 changes: 83 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,91 @@ To regenerate the search index manually after editing page content:
node scripts/extract-search-content.mjs
```

## Docker
## Environment variables

| Variable | Required | Description |
|---|---|---|
| `OPENROUTER_API_KEY` | Yes | API key for the AI chat ([openrouter.ai](https://openrouter.ai)) |
| `AI_MODEL` | No | OpenRouter model ID (default: `google/gemini-3.1-flash-lite`) |
| `DATABASE_URL` | Yes | SQLite connection string — `file:./dev.db` for local dev |

Copy `.env.example` to `.env` and fill in the values before running the app.

## Analytics

Visitor events (file opens, link clicks, sidebar navigation) and chat conversations are stored in a local SQLite database via Prisma.

<img src="./public/images/analytics-overview.png" alt="Portfolio Overview" width="850" />

Run the migration once before the first start:

```bash
npx prisma migrate dev
```

The admin dashboard is available at `/admin`. In development it is accessible without authentication. In production it is protected by Traefik BasicAuth (see below).

## Docker & Traefik deployment

The `docker-compose.yml` is designed for a server running Traefik as a reverse proxy.

### 1. Create `.env` on the server

```bash
cp .env.example .env
```

Set at minimum:

```env
OPENROUTER_API_KEY=sk-or-v1-...

# Generate with: htpasswd -nB admin | sed 's/\$/\$\$/g'
# Every $ in the bcrypt hash must be written as $$ for docker-compose interpolation
ADMIN_BASIC_AUTH=admin:$$2y$$05$$...
```

`DATABASE_URL` is set inside `docker-compose.yml` to `file:/data/portfolio.db` and does **not** need to be in `.env`.

### 2. Generate the admin password hash

```bash
# Requires apache2-utils / httpd-tools — or use Docker:
docker run --rm httpd htpasswd -nB admin

# Escape $ signs for docker-compose (run in bash):
htpasswd -nB admin | sed 's/\$/\$\$/g'
```

Paste the result as `ADMIN_BASIC_AUTH` in `.env`.

### 3. Start the container

```bash
docker compose pull && docker compose up -d
```

The portfolio is then live at `https://merten.tech`. The admin dashboard at `https://merten.tech/admin` is protected by a browser login prompt — Traefik intercepts the request before it reaches the app.

### How the auth routing works

Two Traefik routers are configured for the same container:

| Router | Rule | Middleware |
|---|---|---|
| `merten-portfolio` | `Host(...)` | redirect `mertendieckmann.de → merten.tech` |
| `merten-portfolio-admin` | `Host(...) && PathPrefix(/admin)` | redirect + BasicAuth |

Traefik automatically assigns higher priority to the more specific `/admin` router (longer rule), so every request to `/admin` is challenged for credentials first.

### Database persistence

Analytics data lives in a Docker named volume (`portfolio-data`) mounted at `/data` inside the container. It survives container restarts and image updates.

```bash
docker compose up
# Backup
docker exec merten-portfolio cp /data/portfolio.db /tmp/backup.db
docker cp merten-portfolio:/tmp/backup.db ./portfolio-backup.db
```

## Adding content
Expand Down
2 changes: 2 additions & 0 deletions actions/muscleGroupApiActions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use server"

export async function fetchAvailableMuscleGroups(): Promise<string[]> {
return ["biceps", "triceps", "shoulders", "chest", "back", "legs", "core", "glutes", "calves", "forearms"]

return await fetch("https://gym-api.mertendieckmann.de/getMuscleGroups")
.then(response => {
if (!response.ok) {
Expand Down
37 changes: 37 additions & 0 deletions app/(portfolio)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client"

import React, { Suspense, useState } from "react"
import { NuqsAdapter } from "nuqs/adapters/next"
import { QueryClientProvider } from "@tanstack/react-query"
import { QueryClient } from "@tanstack/query-core"
import { FileSystemProvider } from "@/context/file-system-context"
import { ChatProvider } from "@/context/chat-context"
import { SideBarProvider } from "@/context/side-bar-context"
import { AppHeader } from "@/components/portfolio/app-header"
import { SideNav } from "@/components/portfolio/side-nav"

export default function PortfolioLayout({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())

return (
<Suspense fallback={null}>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<FileSystemProvider>
<ChatProvider>
<SideBarProvider>
<div className="h-screen bg-background text-foreground font-sans selection:bg-primary/30 flex flex-col overflow-hidden">
<AppHeader />
<div className="flex flex-row flex-1 min-h-0">
<SideNav />
{children}
</div>
</div>
</SideBarProvider>
</ChatProvider>
</FileSystemProvider>
</QueryClientProvider>
</NuqsAdapter>
</Suspense>
)
}
2 changes: 1 addition & 1 deletion app/page.tsx → app/(portfolio)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function PortfolioPage() {
<div className="hidden md:flex min-h-0 h-full self-stretch w-full min-w-0">
<ResizablePanelGroup
orientation="horizontal"
className={cn("min-h-0 h-full self-stretch min-w-0", /*!isOpen && "invisible"*/)}
className={cn("min-h-0 h-full self-stretch min-w-0")}
>
<ResizablePanel
panelRef={sidebarRef}
Expand Down
158 changes: 158 additions & 0 deletions app/admin/chat-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"use client"

import { useState, useEffect, useRef } from "react"
import { BotIcon, UserIcon, MessagesSquareIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { ChatSession, ChatMessage } from "./dashboard"

interface Props {
sessions: ChatSession[]
}

export function ChatTab({ sessions }: Props) {
const [selected, setSelected] = useState<ChatSession | null>(sessions[0] ?? null)
const scrollRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [selected])

if (sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground">
<MessagesSquareIcon className="w-8 h-8 opacity-30" />
<p className="text-sm">No chat sessions yet.</p>
</div>
)
}

return (
<div className="flex h-full overflow-hidden">
{/* Session list */}
<aside className="w-64 shrink-0 border-r border-border flex flex-col overflow-hidden">
<div className="px-4 py-3 border-b border-border shrink-0">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{sessions.length} Session{sessions.length !== 1 ? "s" : ""}
</p>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.map((s) => {
const firstUserMsg = s.messages.find((m) => m.role === "USER")
const isSelected = selected?.id === s.id
return (
<button
key={s.id}
onClick={() => setSelected(s)}
className={cn(
"w-full text-left px-4 py-3 border-b border-border/60 transition-colors",
isSelected ? "bg-muted" : "hover:bg-muted/40"
)}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-mono text-muted-foreground">
{formatDateTime(s.createdAt)}
</span>
<span className="text-[10px] bg-muted text-muted-foreground rounded-full px-1.5 py-0.5">
{s.messages.length}
</span>
</div>
{firstUserMsg && (
<p className="text-xs text-foreground truncate leading-relaxed">
{firstUserMsg.content}
</p>
)}
</button>
)
})}
</div>
</aside>

{/* Conversation */}
<main className="flex-1 min-w-0 flex flex-col overflow-hidden bg-background">
{selected ? (
<>
<div className="px-5 py-3 border-b border-border shrink-0 flex items-center justify-between">
<div>
<p className="text-xs font-medium">{formatDateTimeLong(selected.createdAt)}</p>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5 truncate max-w-xs">
{selected.sessionId}
</p>
</div>
<span className="text-xs text-muted-foreground">
{selected.messages.length} messages
</span>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-5 space-y-4">
{selected.messages.map((msg) =>
msg.role === "USER" ? (
<UserBubble key={msg.id} message={msg} />
) : (
<AssistantBubble key={msg.id} message={msg} />
)
)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Select a session
</div>
)}
</main>
</div>
)
}

function AssistantBubble({ message }: { message: ChatMessage }) {
return (
<div className="flex gap-2 justify-start">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
<BotIcon className="w-3.5 h-3.5 text-primary" />
</div>
<div className="flex flex-col gap-1 max-w-[75%]">
<div className="px-3 py-2 rounded-lg text-sm bg-muted text-foreground rounded-bl-none whitespace-pre-wrap break-words">
{message.content}
</div>
<span className="text-[10px] text-muted-foreground font-mono pl-1">
{formatTime(message.createdAt)}
</span>
</div>
</div>
)
}

function UserBubble({ message }: { message: ChatMessage }) {
return (
<div className="flex gap-2 justify-end">
<div className="flex flex-col gap-1 items-end max-w-[75%]">
<div className="px-3 py-2 rounded-lg text-sm bg-primary text-primary-foreground rounded-br-none whitespace-pre-wrap break-words">
{message.content}
</div>
<span className="text-[10px] text-muted-foreground font-mono pr-1">
{formatTime(message.createdAt)}
</span>
</div>
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center mt-0.5">
<UserIcon className="w-3.5 h-3.5 text-primary" />
</div>
</div>
)
}

function formatDateTime(iso: string) {
const d = new Date(iso)
return d.toLocaleDateString("de", { day: "2-digit", month: "2-digit" }) +
" · " + d.toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" })
}

function formatDateTimeLong(iso: string) {
return new Date(iso).toLocaleString("de", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
})
}

function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" })
}
Loading
Loading