diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b63b66b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +data +.env +.env.* +*.log +.git +.github +photos diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0498bc8 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Path to your photo library (read-only mount in production) +PHOTO_DIR=./photos + +# Where the SQLite DB and generated posters live (writable volume) +DB_PATH=./data/library.db +POSTER_DIR=./data/posters + +# API server port +PORT=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da23647 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + name: Build & test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build client + run: npm run build diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..1975a0f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,50 @@ +name: Docker + +on: + push: + branches: [main] + tags: ['v*.*.*'] + +jobs: + build-push: + name: Build & push image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 27bdffb..8419bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ .env +data/ +photos/ *.db *.db-shm *.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ffb1c46 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# ── Stage 1: build client ──────────────────────────────────────────────────── +FROM node:22-alpine AS builder + +# Build tools needed by better-sqlite3 native addon +RUN apk add --no-cache python3 make g++ + +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# ── Stage 2: runtime ───────────────────────────────────────────────────────── +FROM node:22-alpine + +RUN apk add --no-cache python3 make g++ + +WORKDIR /app + +COPY package*.json ./ +# Reinstall in the runtime image so native addons compile for this arch +RUN npm ci --omit=dev + +COPY --from=builder /app/dist ./dist +COPY worker ./worker +COPY server ./server + +ENV PORT=3000 \ + DB_PATH=/data/library.db \ + POSTER_DIR=/data/posters \ + PHOTO_DIR=/photos \ + NODE_ENV=production + +EXPOSE 3000 + +# /data — SQLite database + generated thumbnails (mount a named volume) +# /photos — read-only source photo directory (bind-mount from host) +VOLUME ["/data", "/photos"] + +CMD ["node", "server/index.js"] diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..2a98c1d --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + +
+ + ++ {error} +
+ )} + {photos.length === 0 && !loading && !error && ( +No photos match these filters.
+ ) : ( + <> +No photos indexed yet.
+Set PHOTO_DIR in .env and run npm run worker.
Loading…
} + {nextCursor === null && photos.length > 0 && !loading && ( ++ — {total?.toLocaleString()} photo{total === 1 ? '' : 's'} — +
+ )} + > + )} + + {/* ── Map ── */} + {view === 'map' && ( +{error}
+ )} + + {groups === null && ( +Loading…
+ )} + + {groups !== null && groups.length === 0 && ( +No similar photos to review.
+Click "Scan library" to compare perceptual hashes across your library.
++ {groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss +
+ {groups.map((group, i) => ( +{error}
+ ); + + if (points === null) return ( +Loading map…
+ ); + + if (points.length === 0) return ( +No geotagged photos.
+Photos with GPS metadata will appear here once indexed.
+