9 Commits

Author SHA1 Message Date
Lukas
369feb3cc8 Add deploy step to CI workflow to auto-restart container on tag push
All checks were successful
CI / check (push) Successful in 43s
CI / build-image (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:34 +01:00
Lukas
51bdb799ae Skip lifecycle scripts in Docker build to avoid missing git
All checks were successful
CI / check (push) Successful in 47s
CI / build-image (push) Successful in 29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:06:49 +01:00
Lukas
1baddad939 Remove pnpm cache from setup-node to fix Gitea Actions timeout
Some checks failed
CI / check (push) Successful in 47s
CI / build-image (push) Failing after 18s
2026-03-12 09:52:16 +01:00
Lukas
e701e4dd70 Run build-image job on host to access Docker CLI
Some checks failed
CI / check (push) Failing after 14m13s
CI / build-image (push) Has been cancelled
The node:22 container doesn't have Docker installed. Running
on the host label executes directly on the VPS where Docker
is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:18:51 +01:00
Lukas
e2b0e7d5ee Exclude .pnpm-store from Biome and add .dockerignore
Some checks failed
CI / check (push) Successful in 10m21s
CI / build-image (push) Failing after 4s
The CI runner's pnpm store lands inside the workspace, causing
Biome to lint/format hundreds of store index JSON files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:46:38 +01:00
Lukas
635e9c0705 Add Dockerfile, nginx config, and Gitea Actions CI workflow
Some checks failed
CI / check (push) Failing after 6m20s
CI / build-image (push) Has been skipped
Multi-stage Docker build produces an Nginx container serving the
static SPA. The CI workflow runs pnpm check on every push and
builds/pushes a Docker image on semver tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:32:12 +01:00
Lukas
582a42e62d Change add creature placeholder to "+ Add combatants"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:50:09 +01:00
Lukas
fc43f440aa Toggle pin off when clicking pin on already-pinned creature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:49:00 +01:00
Lukas
1cf30b3622 Add swipe-to-dismiss gesture for mobile stat block drawer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:48:48 +01:00
9 changed files with 174 additions and 4 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.pnpm-store
dist
coverage
.git
.claude
.specify
specs
docs

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm check
build-image:
if: startsWith(github.ref, 'refs/tags/')
needs: check
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latest
- name: Deploy
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker stop initiative || true
docker rm initiative || true
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/domain/package.json packages/domain/
COPY packages/application/package.json packages/application/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN pnpm --filter web build
FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -150,7 +150,9 @@ export function App() {
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId(selectedCreatureId);
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);

View File

@@ -226,7 +226,7 @@ export function ActionBar({
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search creatures to add..."
placeholder="+ Add combatants"
className="max-w-xs"
/>
{suggestions.length > 0 && (

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { StatBlock } from "./stat-block.js";
@@ -176,6 +177,8 @@ function MobileDrawer({
onDismiss: () => void;
children: ReactNode;
}) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
@@ -184,7 +187,13 @@ function MobileDrawer({
onClick={onDismiss}
aria-label="Close stat block"
/>
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"

View File

@@ -0,0 +1,72 @@
import { useCallback, useRef, useState } from "react";
const DISMISS_THRESHOLD = 0.35;
const VELOCITY_THRESHOLD = 0.5;
interface SwipeState {
offsetX: number;
isSwiping: boolean;
}
export function useSwipeToDismiss(onDismiss: () => void) {
const [swipe, setSwipe] = useState<SwipeState>({
offsetX: 0,
isSwiping: false,
});
const startX = useRef(0);
const startY = useRef(0);
const startTime = useRef(0);
const panelWidth = useRef(0);
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
startX.current = touch.clientX;
startY.current = touch.clientY;
startTime.current = Date.now();
directionLocked.current = null;
const el = e.currentTarget as HTMLElement;
panelWidth.current = el.getBoundingClientRect().width;
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
const dx = touch.clientX - startX.current;
const dy = touch.clientY - startY.current;
if (!directionLocked.current) {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
directionLocked.current =
Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
}
if (directionLocked.current === "vertical") return;
const clampedX = Math.max(0, dx);
setSwipe({ offsetX: clampedX, isSwiping: true });
}, []);
const onTouchEnd = useCallback(() => {
if (directionLocked.current !== "horizontal") {
setSwipe({ offsetX: 0, isSwiping: false });
return;
}
const elapsed = (Date.now() - startTime.current) / 1000;
const velocity = swipe.offsetX / elapsed / panelWidth.current;
const ratio =
panelWidth.current > 0 ? swipe.offsetX / panelWidth.current : 0;
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
onDismiss();
}
setSwipe({ offsetX: 0, isSwiping: false });
}, [swipe.offsetX, onDismiss]);
return {
offsetX: swipe.offsetX,
isSwiping: swipe.isSwiping,
handlers: { onTouchStart, onTouchMove, onTouchEnd },
};
}

View File

@@ -7,7 +7,8 @@
"!.claude/**",
"!.specify/**",
"!specs/**",
"!coverage/**"
"!coverage/**",
"!.pnpm-store/**"
]
},
"assist": {

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}