commit 3480888eaa8fe8040f83e3d52a4a841b7b3428f1 Author: michaeltieso Date: Mon Dec 1 14:49:17 2025 +0000 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34853f4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,62 @@ +# Node modules +node_modules/ +npm-debug.log +yarn-error.log + +# Build output +.next/ +out/ +dist/ + +# Environment files +.env +.env.local +.env.*.local + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ +.nyc_output/ + +# Misc +*.log +.cache/ +temp/ +tmp/ + +# Database (don't include existing DB in image) +data/ +*.db +*.db-shm +*.db-wal + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Documentation +README.md +DEPLOYMENT.md +LICENSE + +# Docker files +Dockerfile +docker-compose.yml +.dockerignore + +# Setup scripts +setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5799af --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Admin Credentials (REQUIRED) +# Set a strong username and password for the admin account +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme + +# JWT Secret (Optional - auto-generated if not provided) +# For production, generate a secure random string: +# openssl rand -base64 32 +SECRET= + +# Application Settings (Optional) +NODE_ENV=production +PORT=3000 + +# Timezone for logs (Optional) +TZ=America/New_York + +# Default currency for wishlist items (Optional) +DEFAULT_CURRENCY=USD diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..a440e68 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,41 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/wishlist + tags: | + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/wishlist:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/wishlist:buildcache,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c28067a --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# 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 +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# next.js cache +/.next/cache/ +/.next/server/ +/.next/static/chunks/ + +# swc compiler +.swc/ + +# turbo +.turbo/ + +# drizzle +drizzle/ + +# ide +.vscode/ +.idea/ + +# data +/data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07c8d80 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +# Multi-stage build for Next.js wishlist app + +# Stage 1: Dependencies +FROM node:20-alpine AS deps + +WORKDIR /app + +# Install dependencies needed for native modules (sharp, better-sqlite3) +RUN apk add --no-cache python3 make g++ + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Stage 2: Builder +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules + +# Copy application files +COPY . . + +# Disable Next.js telemetry +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build the application +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine AS runner + +WORKDIR /app + +# Install su-exec for user switching (standard approach) +RUN apk add --no-cache su-exec + +# Create nextjs user and group (for compatibility with systems that expect it) +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 -G nodejs + +# Set production environment +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Expose port +EXPOSE 3000 + +# Ensure we run as root so entrypoint can manage users +USER root + +# Use entrypoint to handle PUID/PGID (LinuxServer.io pattern) +ENTRYPOINT ["/entrypoint.sh"] + +# Start the application +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..084b1a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Wishlist Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..09995a3 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Wishlist + +[![Docker Hub](https://img.shields.io/docker/pulls/reggiodigital/wishlist)](https://hub.docker.com/r/reggiodigital/wishlist) +[![License](https://img.shields.io/github/license/Reggio-Digital/wishlist)](https://github.com/Reggio-Digital/wishlist/blob/main/LICENSE) + +A simple, self-hosted wishlist app for sharing gift ideas with family and friends. + +## Why This App? + +Most wishlist apps are bloated with features you don't need, require accounts for everyone, or lock you into a platform. This app solves a simple problem: you want to share what you'd like as gifts, and your friends and family want to claim items without spoiling the surprise. + +**Features:** + +- **Simple** - No complex features, just wishlists and items +- **Easy to Share** - Send a single URL, no signups required +- **Multiple Purchase Links** - Add multiple store links for each item so people can choose where to buy +- **Public/Private Wishlists** - Keep lists private while you're working on them, then make them public when ready +- **No Peeking!** - Admins can't see claimed items from the dashboard - you'd have to visit the specific wishlist's public URL to spoil the surprise +- **Privacy-Focused** - Self-hosted, your data stays with you +- **Transparent** - Anyone viewing the list can see what's been claimed to avoid duplicates +- **Low Maintenance** - Single Docker container with SQLite, no database setup needed +- **URL Scraping** - Auto-fill item details from product URLs _(Coming Soon)_ + +## Demo + +![Demo Video](video.mp4) + +## Screenshots + +### Homepage + +![Homepage](screenshot1.png) + +### Wishlist View + +![Wishlist View](screenshot2.png) + +### Admin Dashboard + +![Admin Dashboard](screenshot3.png) + +### Admin Dashboard - Item Details + +![Admin Dashboard - Item Details](screenshot4.png) + +## Quick Start + +### Using Docker Compose + +```bash +# Clone and configure +git clone https://github.com/Reggio-Digital/wishlist +cd wishlist +cp .env.example .env + +# Edit .env with your admin credentials +nano .env + +# Start with Docker Compose +docker-compose up -d +``` + +Visit http://localhost:3000 + +### Using Docker Image + +```bash +docker run -d \ + -p 3000:3000 \ + -e PUID=1000 \ + -e PGID=1000 \ + -e ADMIN_USERNAME=admin \ + -e ADMIN_PASSWORD=your-secure-password \ + -v wishlist-data:/app/data \ + --name wishlist \ + reggiodigital/wishlist:latest +``` + +**For Unraid users:** Set `-e PUID=99 -e PGID=100` + +## Data Storage + +Data is stored in `/app/data`: + +- `/app/data/db` - SQLite database files +- `/app/data/uploads` - Uploaded images + +## Environment Variables + +Create a `.env` file: + +```env +# Required - Admin Credentials +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme + +# Optional - User/Group IDs for docker-compose (defaults to 1000:1000) +# For Unraid, use PUID=99 and PGID=100 +PUID=1000 +PGID=1000 + +# Optional - JWT Secret (auto-generated if not provided) +# Generate with: openssl rand -base64 32 +SECRET= +``` + +### User Permissions (PUID/PGID) + +The container automatically handles file permissions using PUID/PGID environment variables (LinuxServer.io pattern): + +- **Default:** `1000:1000` (standard Linux user) +- **Unraid:** Set `PUID=99` and `PGID=100` (nobody:users) +- **Find your IDs:** Run `id` on your system + +Example for Unraid in `.env`: +```env +PUID=99 +PGID=100 +``` + +The entrypoint script automatically: +- Creates the user/group if needed +- Sets correct ownership on data directories +- Ensures proper file permissions for uploads + +## Development + +```bash +# Install dependencies +npm install + +# Run development server +npm run dev + +# Build for production +npm run build + +# Start production server +npm start +``` + +## Pages + +- `/admin/login` - Admin authentication +- `/admin` - Admin dashboard (manage wishlists and items) +- `/[slug]` - Public wishlist view + +## License + +MIT + +--- + +Made with ❤️ by [Reggio Digital](https://reggiodigital.com) diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx new file mode 100644 index 0000000..c69e527 --- /dev/null +++ b/app/[slug]/page.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api'; +import Header from '@/components/header'; +import Footer from '@/components/footer'; +import PasswordLockGuard from '@/components/password-lock-guard'; + +export default function PublicWishlistPage() { + const params = useParams(); + const [wishlist, setWishlist] = useState(null); + const [items, setItems] = useState([]); + const [showClaimed, setShowClaimed] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + // Claim form state + const [claimingItemId, setClaimingItemId] = useState(null); + const [claimNote, setClaimNote] = useState(''); + const [isClaiming, setIsClaiming] = useState(false); + const [claimError, setClaimError] = useState(''); + const [justClaimedItemId, setJustClaimedItemId] = useState(null); + const [justClaimedNote, setJustClaimedNote] = useState(''); + + // Unclaim state + const [isUnclaiming, setIsUnclaiming] = useState(false); + const [unclaimError, setUnclaimError] = useState(''); + + useEffect(() => { + fetchWishlist(); + }, [params.slug]); + + const fetchWishlist = async () => { + if (!params.slug) return; + + try { + const wishlistData = await wishlistsApi.getBySlug(params.slug as string); + setWishlist(wishlistData); + + const itemsData = await itemsApi.getAll(wishlistData.id); + setItems(itemsData.sort((a, b) => a.sortOrder - b.sortOrder)); + } catch (err: any) { + setError(err.message || 'Wishlist not found'); + } finally { + setIsLoading(false); + } + }; + + const handleClaimItem = (itemId: string) => { + setClaimingItemId(itemId); + setClaimError(''); + setClaimNote(''); + setJustClaimedItemId(null); + }; + + const handleSubmitClaim = async (e: React.FormEvent, itemId: string) => { + e.preventDefault(); + + setIsClaiming(true); + setClaimError(''); + + try { + await claimingApi.claim(itemId, undefined, claimNote); + + setJustClaimedItemId(itemId); + setJustClaimedNote(claimNote); + setClaimingItemId(null); + setClaimNote(''); + fetchWishlist(); + } catch (err: any) { + setClaimError(err.message || 'Failed to claim item'); + } finally { + setIsClaiming(false); + } + }; + + const handleUnclaim = async (itemId: string) => { + if (!confirm('Are you sure you want to unclaim this item?')) { + return; + } + + setIsUnclaiming(true); + setUnclaimError(''); + + try { + await claimingApi.unclaim(itemId); + fetchWishlist(); + } catch (err: any) { + setUnclaimError(err.message || 'Failed to unclaim item'); + } finally { + setIsUnclaiming(false); + } + }; + + const filteredItems = showClaimed + ? items + : items.filter((item) => !item.claimedAt || item.id === justClaimedItemId); + + const formatPrice = (price: number | null, currency: string) => { + if (!price) return null; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD', + }).format(price); + }; + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error || !wishlist) { + return ( +
+
+

Wishlist Not Found

+

{error || 'This wishlist does not exist or is not public.'}

+
+
+ ); + } + + return ( + +
+
+ + {/* Main Content */} +
+
+ + + + + Back to Home + + + {/* Preferences Section */} + {wishlist.preferences && ( +
+

+ General Interests & Preferences +

+
{ + // Make all links open in new tab + const target = e.target as HTMLElement; + if (target.tagName === 'A') { + e.preventDefault(); + window.open((target as HTMLAnchorElement).href, '_blank', 'noopener,noreferrer'); + } + }} + /> +
+ )} + + {/* Controls */} +
+
+ +
+
+ {filteredItems.length} of {items.length} items +
+
+ + {/* Items List */} + {filteredItems.length === 0 ? ( +
+

+ {showClaimed ? 'No items in this wishlist yet' : 'All items have been claimed!'} +

+
+ ) : ( +
+ {filteredItems.map((item) => ( +
+
+ {/* Left: Image */} + {item.imageUrl && ( +
+ {item.name} +
+ )} + + {/* Middle: Item Details */} +
+

+ {item.name} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+ + {/* Right: Action Area */} +
+
+ {item.purchaseUrls && item.purchaseUrls.length > 0 && ( + + )} +
+ + {/* Claimed Badge, Success Message, or Claim Button/Form */} +
+ {justClaimedItemId === item.id ? ( +
+
+
+ + + +
+
+

+ Item Claimed! +

+

+ The status is now locked. +

+ {justClaimedNote && ( +

+ Your Note: "{justClaimedNote}" +

+ )} +
+ ) : item.claimedAt ? ( +
+

+ Claimed by {item.claimedByName} +

+ {item.claimedByNote && ( +

+ Note: {item.claimedByNote} +

+ )} + {item.isPurchased && ( +

+ ✓ Purchased +

+ )} + {showClaimed && ( + + )} +
+ ) : claimingItemId === item.id ? ( +
+
handleSubmitClaim(e, item.id)} className="space-y-3"> + {claimError && ( +
+ {claimError} +
+ )} + +
+ +