Initial commit

This commit is contained in:
michaeltieso
2025-12-01 14:49:17 +00:00
commit 3480888eaa
92 changed files with 16631 additions and 0 deletions

62
.dockerignore Normal file
View File

@@ -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

19
.env.example Normal file
View File

@@ -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

41
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -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

63
.gitignore vendored Normal file
View File

@@ -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

69
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

154
README.md Normal file
View File

@@ -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)

364
app/[slug]/page.tsx Normal file
View File

@@ -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<Wishlist | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [showClaimed, setShowClaimed] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
// Claim form state
const [claimingItemId, setClaimingItemId] = useState<string | null>(null);
const [claimNote, setClaimNote] = useState('');
const [isClaiming, setIsClaiming] = useState(false);
const [claimError, setClaimError] = useState('');
const [justClaimedItemId, setJustClaimedItemId] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
if (error || !wishlist) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Wishlist Not Found</h1>
<p className="text-gray-600 dark:text-gray-400">{error || 'This wishlist does not exist or is not public.'}</p>
</div>
</div>
);
}
return (
<PasswordLockGuard>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title={wishlist.name}
subtitle={wishlist.description || undefined}
imageUrl={wishlist.imageUrl || undefined}
maxWidth="max-w-5xl"
/>
{/* Main Content */}
<div className="max-w-5xl mx-auto py-12 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
<a
href="/"
className="inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 mb-6 transition-colors cursor-pointer"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Back to Home
</a>
{/* Preferences Section */}
{wishlist.preferences && (
<div className="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-3">
General Interests & Preferences
</h2>
<div
className="prose prose-indigo dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 [&_a]:text-indigo-600 [&_a]:dark:text-indigo-400 [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: wishlist.preferences }}
onClick={(e) => {
// 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');
}
}}
/>
</div>
)}
{/* Controls */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={showClaimed}
onChange={(e) => setShowClaimed(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">Show claimed items</span>
</label>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{filteredItems.length} of {items.length} items
</div>
</div>
{/* Items List */}
{filteredItems.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
<p className="text-gray-500 dark:text-gray-400">
{showClaimed ? 'No items in this wishlist yet' : 'All items have been claimed!'}
</p>
</div>
) : (
<div className="space-y-6">
{filteredItems.map((item) => (
<div
key={item.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 hover:scale-105 overflow-hidden"
>
<div className="flex flex-col md:flex-row">
{/* Left: Image */}
{item.imageUrl && (
<div className="md:w-48 md:flex-shrink-0">
<img
src={item.imageUrl}
alt={item.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
)}
{/* Middle: Item Details */}
<div className="flex-1 p-6">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{item.name}
</h3>
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
{item.description}
</p>
)}
</div>
{/* Right: Action Area */}
<div className="md:w-80 md:flex-shrink-0 p-6 bg-gray-50 dark:bg-gray-900/50 border-t md:border-t-0 md:border-l border-gray-200 dark:border-gray-700 flex flex-col">
<div className="mb-4">
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
<div className="space-y-2">
{item.purchaseUrls.map((url, idx) => (
<a
key={idx}
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between text-base px-4 py-3 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
>
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
{url.label}
</span>
<span className="text-gray-900 dark:text-white font-bold text-lg">
{item.price && formatPrice(item.price, item.currency)}
</span>
</a>
))}
</div>
)}
</div>
{/* Claimed Badge, Success Message, or Claim Button/Form */}
<div className="mt-auto">
{justClaimedItemId === item.id ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center justify-center mb-2">
<div className="w-12 h-12 bg-green-500 dark:bg-green-600 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<p className="text-center text-lg font-semibold text-gray-900 dark:text-white mb-1">
Item Claimed!
</p>
<p className="text-center text-sm text-gray-600 dark:text-gray-400 mb-2">
The status is now locked.
</p>
{justClaimedNote && (
<p className="text-center text-xs text-gray-600 dark:text-gray-400 italic">
Your Note: &quot;{justClaimedNote}&quot;
</p>
)}
</div>
) : item.claimedAt ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded p-3">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Claimed by {item.claimedByName}
</p>
{item.claimedByNote && (
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
Note: {item.claimedByNote}
</p>
)}
{item.isPurchased && (
<p className="text-xs text-green-700 dark:text-green-300 mt-1 font-medium">
Purchased
</p>
)}
{showClaimed && (
<button
onClick={() => handleUnclaim(item.id)}
disabled={isUnclaiming}
className="mt-3 w-full px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 font-medium disabled:opacity-50 transition-colors cursor-pointer text-sm"
>
{isUnclaiming ? 'Unclaiming...' : 'Unclaim Item'}
</button>
)}
</div>
) : claimingItemId === item.id ? (
<div className="space-y-3">
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
{claimError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
{claimError}
</div>
)}
<div>
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Add a Note (Optional):
</label>
<textarea
id={`claim-note-${item.id}`}
rows={3}
placeholder="Let them know your plans! e.g., 'Buying this next week' or 'Found a great deal online' or 'Need to check the size first'"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white resize-none"
value={claimNote}
onChange={(e) => setClaimNote(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isClaiming}
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
>
{isClaiming ? 'Claiming...' : 'Confirm Claim'}
</button>
</form>
</div>
) : (
<button
onClick={() => handleClaimItem(item.id)}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
>
Claim This Item
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<Footer />
</div>
</PasswordLockGuard>
);
}

140
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import type { ApiError } from '@/lib/api';
export default function AdminLoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
// Redirect if already logged in
useEffect(() => {
if (!authLoading && isAuthenticated) {
router.push('/admin');
}
}, [isAuthenticated, authLoading, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(username, password);
router.push('/admin');
} catch (err) {
const apiError = err as ApiError;
setError(apiError.message || 'Login failed');
} finally {
setIsLoading(false);
}
};
// Show loading while checking auth status
if (authLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
// Don't render login form if already authenticated (will redirect)
if (isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Hero Section */}
<div className="bg-white dark:bg-gray-800 shadow-sm">
<div className="max-w-7xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
Admin Login
</h1>
<p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-6">
Sign in to your account
</p>
<Link
href="/"
className="inline-flex items-center text-base font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 transition-colors"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Home
</Link>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-4xl mx-auto py-12 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
<div className="max-w-md mx-auto">
<form className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-100 dark:border-gray-700 p-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-base"
placeholder="admin"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-base"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg transition-all"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}

238
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,238 @@
'use client';
import { useEffect, useState } from 'react';
import ProtectedRoute from '@/components/protected-route';
import { useAuth } from '@/lib/auth-context';
import { wishlistsApi, itemsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
import Header from '@/components/header';
import Footer from '@/components/footer';
import Link from 'next/link';
import StatsGrid from '@/components/admin/StatsGrid';
import SettingsSection from '@/components/admin/SettingsSection';
import WishlistCard from '@/components/admin/WishlistCard';
import CreateWishlistModal from '@/components/admin/CreateWishlistModal';
import ShareButton from '@/components/share-button';
export default function AdminPage() {
const { logout } = useAuth();
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
const [itemCounts, setItemCounts] = useState<Record<string, number>>({});
const [isLoading, setIsLoading] = useState(true);
const [settings, setSettings] = useState<Settings>({
siteTitle: 'Wishlist',
homepageSubtext: 'Browse and explore available wishlists',
passwordLockEnabled: false,
});
const [showCreateModal, setShowCreateModal] = useState(false);
const [createError, setCreateError] = useState('');
useEffect(() => {
fetchWishlists();
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const data = await settingsApi.getSettings();
setSettings(data);
} catch (error) {
console.error('Failed to fetch settings:', error);
}
};
const fetchWishlists = async () => {
try {
const data = await wishlistsApi.getAll();
setWishlists(data);
// Fetch item counts for each wishlist
const counts: Record<string, number> = {};
await Promise.all(
data.map(async (w) => {
const items = await itemsApi.getAll(w.id);
counts[w.id] = items.length;
})
);
setItemCounts(counts);
} catch (error) {
console.error('Failed to fetch wishlists:', error);
} finally {
setIsLoading(false);
}
};
const handleUpdateSettings = async (updatedSettings: Settings) => {
await settingsApi.updateSettings(updatedSettings);
setSettings(updatedSettings);
};
const handleCreateWishlist = async (data: any) => {
setCreateError('');
try {
await wishlistsApi.create(data);
setShowCreateModal(false);
fetchWishlists();
} catch (error: any) {
setCreateError(error.message || 'Failed to create wishlist');
throw error;
}
};
const handleUpdateWishlist = async (id: string, data: Partial<Wishlist>) => {
await wishlistsApi.update(id, data);
fetchWishlists();
};
const handleDeleteWishlist = async (id: string) => {
if (!confirm('Are you sure you want to delete this wishlist?')) return;
try {
await wishlistsApi.delete(id);
fetchWishlists();
} catch (error) {
alert('Failed to delete wishlist');
}
};
const handleMoveWishlistUp = async (wishlistId: string) => {
const currentIndex = wishlists.findIndex((w) => w.id === wishlistId);
if (currentIndex <= 0) return;
try {
await wishlistsApi.reorder(wishlistId, currentIndex - 1);
await fetchWishlists();
} catch (error: any) {
alert(`Error: ${error?.message || 'Failed to reorder wishlist'}`);
}
};
const handleMoveWishlistDown = async (wishlistId: string) => {
const currentIndex = wishlists.findIndex((w) => w.id === wishlistId);
if (currentIndex === -1 || currentIndex === wishlists.length - 1) return;
try {
await wishlistsApi.reorder(wishlistId, currentIndex + 1);
await fetchWishlists();
} catch (error: any) {
alert(`Error: ${error?.message || 'Failed to reorder wishlist'}`);
}
};
const stats = {
totalWishlists: wishlists.length,
publicWishlists: wishlists.filter((w) => w.isPublic).length,
totalItems: Object.values(itemCounts).reduce((sum, count) => sum + count, 0),
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title="Dashboard"
subtitle="Manage your wishlists and items"
actions={
<>
<ShareButton
title="Check out my wishlist site!"
text="I wanted to share my wishlist site with you."
url="https://wishlist.tieso.co/"
/>
<Link
href="/"
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
>
View Public Site
</Link>
<button
onClick={logout}
className="inline-flex items-center px-6 py-3 border-2 border-red-600 dark:border-red-500 text-base font-semibold rounded-lg text-red-600 dark:text-red-400 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all cursor-pointer"
title="Logout"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Logout
</button>
</>
}
/>
<div className="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
) : (
<>
{/* Stats Grid */}
<StatsGrid stats={stats} />
{/* Settings Section */}
<SettingsSection settings={settings} onUpdate={handleUpdateSettings} />
{/* Wishlists Section */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Your Wishlists
</h2>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all cursor-pointer"
>
+ Create Wishlist
</button>
</div>
{wishlists.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p className="text-gray-500 dark:text-gray-400 mb-6 text-lg">
No wishlists yet
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all"
>
Create Your First Wishlist
</button>
</div>
) : (
<div className="space-y-3">
{wishlists.map((wishlist, index) => (
<WishlistCard
key={wishlist.id}
wishlist={wishlist}
itemCount={itemCounts[wishlist.id] || 0}
onUpdate={handleUpdateWishlist}
onDelete={handleDeleteWishlist}
onMoveUp={handleMoveWishlistUp}
onMoveDown={handleMoveWishlistDown}
isFirst={index === 0}
isLast={index === wishlists.length - 1}
onItemsChange={fetchWishlists}
/>
))}
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Create Modal */}
<CreateWishlistModal
isOpen={showCreateModal}
onClose={() => {
setShowCreateModal(false);
setCreateError('');
}}
onCreate={handleCreateWishlist}
error={createError}
/>
<Footer />
</div>
</ProtectedRoute>
);
}

44
app/api/[slug]/route.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.slug, slug))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Only return public wishlists
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
wishlist: wishlist[0],
});
} catch (error) {
console.error('Error fetching wishlist by slug:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlist' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, validateAdminCredentials } from '@/lib/auth/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
// Validate credentials
if (!validateAdminCredentials(username, password)) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Generate tokens
const accessToken = generateAccessToken(username);
const refreshToken = generateRefreshToken(username);
// Create response
const response = NextResponse.json({
success: true,
user: { username },
accessToken,
refreshToken,
});
// Set cookies
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', accessToken, {
...cookieOptions,
maxAge: 72 * 60 * 60, // 72 hours
});
response.cookies.set('refresh_token', refreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const response = NextResponse.json({
success: true,
message: 'Logged out successfully'
});
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return response;
}

34
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
user: { username: payload.username },
});
} catch (error) {
console.error('Auth error:', error);
return NextResponse.json(
{ error: 'Authentication failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '@/lib/auth/utils';
export async function POST(request: NextRequest) {
try {
// Get refresh token from cookie or body
let refreshToken: string | undefined;
const cookieToken = request.cookies.get('refresh_token')?.value;
if (cookieToken) {
refreshToken = cookieToken;
} else {
try {
const body = await request.json();
refreshToken = body.refreshToken;
} catch {
// No body or invalid JSON, continue without it
refreshToken = undefined;
}
}
if (!refreshToken) {
return NextResponse.json(
{ error: 'No refresh token provided' },
{ status: 401 }
);
}
// Verify refresh token
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired refresh token' },
{ status: 401 }
);
}
// Generate new tokens
const newAccessToken = generateAccessToken(payload.username);
const newRefreshToken = generateRefreshToken(payload.username);
// Create response
const response = NextResponse.json({
success: true,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
// Set new cookies
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', newAccessToken, {
...cookieOptions,
maxAge: 72 * 60 * 60, // 72 hours
});
response.cookies.set('refresh_token', newRefreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error('Refresh error:', error);
return NextResponse.json(
{ error: 'Token refresh failed' },
{ status: 500 }
);
}
}

9
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { newSortOrder } = body;
if (newSortOrder === undefined || typeof newSortOrder !== 'number') {
return NextResponse.json(
{ error: 'newSortOrder is required and must be a number' },
{ status: 400 }
);
}
// Validate newSortOrder is not negative
if (newSortOrder < 0) {
return NextResponse.json(
{ error: 'newSortOrder must be a non-negative number' },
{ status: 400 }
);
}
// Check if item exists
const existingItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (existingItem.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
const oldSortOrder = existingItem[0].sortOrder;
const wishlistId = existingItem[0].wishlistId;
// Get all items for this wishlist sorted by sortOrder
const allItems = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.wishlistId, wishlistId))
.orderBy(wishlistItems.sortOrder);
// Validate newSortOrder is within bounds
if (newSortOrder >= allItems.length) {
return NextResponse.json(
{ error: `newSortOrder must be less than ${allItems.length}` },
{ status: 400 }
);
}
// Skip if no change needed
if (oldSortOrder === newSortOrder) {
return NextResponse.json({
success: true,
item: existingItem[0],
});
}
// Remove the item being moved from the array
const movingItemIndex = allItems.findIndex(item => item.id === id);
// Safety check: ensure item was found in the array
if (movingItemIndex === -1) {
console.error(`Item ${id} not found in wishlist items array`);
return NextResponse.json(
{ error: 'Item not found in wishlist' },
{ status: 500 }
);
}
const movingItem = allItems[movingItemIndex];
allItems.splice(movingItemIndex, 1);
// Insert it at the new position
allItems.splice(newSortOrder, 0, movingItem);
// Update all sortOrders in a transaction for atomicity
const updatedItem = await db.transaction(async (tx) => {
// Update all sortOrders
for (let i = 0; i < allItems.length; i++) {
await tx
.update(wishlistItems)
.set({
sortOrder: i,
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, allItems[i].id));
}
// Get the updated moving item
const result = await tx
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
return result[0];
});
return NextResponse.json({
success: true,
item: updatedItem,
});
} catch (error) {
console.error('Error reordering item:', error);
return NextResponse.json(
{ error: 'Failed to reorder item' },
{ status: 500 }
);
}
}

204
app/api/items/[id]/route.ts Normal file
View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Check for auth token
const token = request.cookies.get('access_token')?.value;
const payload = token ? verifyAccessToken(token) : null;
const isAuthenticated = payload !== null;
// Get item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Check permissions
if (!wishlist[0].isPublic && !isAuthenticated) {
return NextResponse.json(
{ error: 'This item is private' },
{ status: 403 }
);
}
return NextResponse.json({
success: true,
item: item[0],
});
} catch (error) {
console.error('Error fetching item:', error);
return NextResponse.json(
{ error: 'Failed to fetch item' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const {
name,
description,
price,
currency,
quantity,
imageUrl,
purchaseUrls,
isArchived,
} = body;
// Check if item exists
const existingItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (existingItem.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Build update object (only include provided fields)
const updateData: any = {
updatedAt: new Date(),
};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (price !== undefined) updateData.price = price;
if (currency !== undefined) updateData.currency = currency;
if (quantity !== undefined) updateData.quantity = quantity;
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (purchaseUrls !== undefined) updateData.purchaseUrls = purchaseUrls;
if (isArchived !== undefined) updateData.isArchived = isArchived;
// Update item
const updatedItem = await db
.update(wishlistItems)
.set(updateData)
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json({
success: true,
item: updatedItem[0],
});
} catch (error) {
console.error('Error updating item:', error);
return NextResponse.json(
{ error: 'Failed to update item' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
// Check if item exists
const existingItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (existingItem.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Delete item
await db
.delete(wishlistItems)
.where(eq(wishlistItems.id, id));
return NextResponse.json({
success: true,
message: 'Item deleted successfully',
});
} catch (error) {
console.error('Error deleting item:', error);
return NextResponse.json(
{ error: 'Failed to delete item' },
{ status: 500 }
);
}
}

67
app/api/lock/route.ts Normal file
View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db';
import crypto from 'crypto';
// POST /api/lock - Verify password
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { password } = body;
if (!password) {
return NextResponse.json(
{ error: 'Password is required' },
{ status: 400 }
);
}
// Get the stored password hash
const hashSetting = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockHash'))
.limit(1);
if (hashSetting.length === 0) {
return NextResponse.json(
{ error: 'Password lock not configured' },
{ status: 400 }
);
}
// Hash the provided password
const hash = crypto.createHash('sha256').update(password).digest('hex');
// Compare hashes
if (hash === hashSetting[0].value) {
// Password correct - set a cookie
const response = NextResponse.json({
success: true,
message: 'Password verified',
});
// Set an unlock cookie that expires in 24 hours
response.cookies.set('site_unlocked', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return response;
} else {
return NextResponse.json(
{ error: 'Incorrect password' },
{ status: 401 }
);
}
} catch (error) {
console.error('Error verifying password:', error);
return NextResponse.json(
{ error: 'Failed to verify password' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { createId } from '@paralleldrive/cuid2';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { name, note } = body;
// Get the item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Check if item is already claimed
if (item[0].claimedByToken) {
return NextResponse.json(
{ error: 'Item is already claimed' },
{ status: 409 }
);
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Generate unique claim token
const claimToken = createId();
// Update item with claim information
const updatedItem = await db
.update(wishlistItems)
.set({
claimedByName: name || null,
claimedByNote: note || null,
claimedByToken: claimToken,
claimedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json(
{
success: true,
claimToken,
item: updatedItem[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error claiming item:', error);
return NextResponse.json(
{ error: 'Failed to claim item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get the item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Check if item is actually claimed
if (!item[0].claimedByToken) {
return NextResponse.json(
{ error: 'Item is not claimed' },
{ status: 400 }
);
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Remove claim information (honor system - no verification)
const updatedItem = await db
.update(wishlistItems)
.set({
claimedByName: null,
claimedByNote: null,
claimedByToken: null,
claimedAt: null,
isPurchased: false,
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json(
{
success: true,
message: 'Item unclaimed successfully',
item: updatedItem[0],
},
{ status: 200 }
);
} catch (error) {
console.error('Error unclaiming item:', error);
return NextResponse.json(
{ error: 'Failed to unclaim item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
// Fetch only public wishlists
const publicWishlists = await db
.select()
.from(wishlists)
.where(eq(wishlists.isPublic, true))
.orderBy(asc(wishlists.sortOrder));
return NextResponse.json({
success: true,
wishlists: publicWishlists,
});
} catch (error) {
console.error('Error fetching public wishlists:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlists' },
{ status: 500 }
);
}
}

63
app/api/scrape/route.ts Normal file
View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils';
import { scrapeUrl } from '@/lib/scraping/service';
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const body = await request.json();
const { url } = body;
// Validation
if (!url) {
return NextResponse.json(
{ error: 'URL is required' },
{ status: 400 }
);
}
// Validate URL format
try {
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
new URL(normalizedUrl);
} catch (error) {
return NextResponse.json(
{ error: 'Invalid URL format' },
{ status: 400 }
);
}
// Scrape the URL
const scrapedData = await scrapeUrl(url);
return NextResponse.json({
success: true,
data: scrapedData,
});
} catch (error) {
console.error('Error scraping URL:', error);
return NextResponse.json(
{
error: 'Failed to scrape URL',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

164
app/api/settings/route.ts Normal file
View File

@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import crypto from 'crypto';
// GET /api/settings - Get all settings (public endpoint for reading only)
export async function GET(request: NextRequest) {
try {
const allSettings = await db.select().from(settings);
// Convert to key-value object
const settingsObj = allSettings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string | boolean>);
// Set defaults if not found
if (!settingsObj.siteTitle) {
settingsObj.siteTitle = 'Wishlist';
}
if (!settingsObj.homepageSubtext) {
settingsObj.homepageSubtext = 'Browse and explore available wishlists';
}
// Convert passwordLockEnabled to boolean
(settingsObj as any).passwordLockEnabled = settingsObj.passwordLockEnabled === 'true';
return NextResponse.json({
success: true,
settings: settingsObj,
});
} catch (error) {
console.error('Error fetching settings:', error);
return NextResponse.json(
{ error: 'Failed to fetch settings' },
{ status: 500 }
);
}
}
// PUT /api/settings - Update settings (admin only)
export async function PUT(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const body = await request.json();
const { siteTitle, homepageSubtext, passwordLockEnabled, passwordLock } = body;
// Update or insert siteTitle
if (siteTitle !== undefined) {
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'siteTitle'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: siteTitle, updatedAt: new Date() })
.where(eq(settings.key, 'siteTitle'));
} else {
await db.insert(settings).values({
key: 'siteTitle',
value: siteTitle,
});
}
}
// Update or insert homepageSubtext
if (homepageSubtext !== undefined) {
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'homepageSubtext'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: homepageSubtext, updatedAt: new Date() })
.where(eq(settings.key, 'homepageSubtext'));
} else {
await db.insert(settings).values({
key: 'homepageSubtext',
value: homepageSubtext,
});
}
}
// Update or insert passwordLockEnabled
if (passwordLockEnabled !== undefined) {
const value = passwordLockEnabled ? 'true' : 'false';
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockEnabled'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value, updatedAt: new Date() })
.where(eq(settings.key, 'passwordLockEnabled'));
} else {
await db.insert(settings).values({
key: 'passwordLockEnabled',
value,
});
}
}
// Update password hash if provided
if (passwordLock && passwordLock.trim() !== '') {
// Hash the password using SHA-256
const hash = crypto.createHash('sha256').update(passwordLock).digest('hex');
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockHash'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: hash, updatedAt: new Date() })
.where(eq(settings.key, 'passwordLockHash'));
} else {
await db.insert(settings).values({
key: 'passwordLockHash',
value: hash,
});
}
}
return NextResponse.json({
success: true,
message: 'Settings updated successfully',
});
} catch (error) {
console.error('Error updating settings:', error);
return NextResponse.json(
{ error: 'Failed to update settings' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,166 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, and, desc } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Check for auth token
const token = request.cookies.get('access_token')?.value;
const payload = token ? verifyAccessToken(token) : null;
const isAuthenticated = payload !== null;
// Check if wishlist exists
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Check permissions
if (!wishlist[0].isPublic && !isAuthenticated) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Get all items (exclude archived unless authenticated)
const items = await db
.select()
.from(wishlistItems)
.where(
isAuthenticated
? eq(wishlistItems.wishlistId, id)
: and(
eq(wishlistItems.wishlistId, id),
eq(wishlistItems.isArchived, false)
)
)
.orderBy(wishlistItems.sortOrder);
// Return items
const responseItems = items;
return NextResponse.json({
success: true,
items: responseItems,
});
} catch (error) {
console.error('Error fetching items:', error);
return NextResponse.json(
{ error: 'Failed to fetch items' },
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const {
name,
description,
price,
currency,
quantity,
imageUrl,
purchaseUrls,
} = body;
// Validation
if (!name) {
return NextResponse.json(
{ error: 'Item name is required' },
{ status: 400 }
);
}
// Check if wishlist exists
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Get the highest sortOrder value to append the new item at the end
const lastItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.wishlistId, id))
.orderBy(desc(wishlistItems.sortOrder))
.limit(1);
const nextSortOrder = lastItem.length > 0 ? lastItem[0].sortOrder + 1 : 0;
// Create item
const newItem = await db
.insert(wishlistItems)
.values({
wishlistId: id,
name,
description: description || null,
price: price || null,
currency: currency || 'USD',
quantity: quantity || 1,
imageUrl: imageUrl || null,
purchaseUrls: purchaseUrls || null,
sortOrder: nextSortOrder,
})
.returning();
return NextResponse.json(
{
success: true,
item: newItem[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error creating item:', error);
return NextResponse.json(
{ error: 'Failed to create item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { newSortOrder } = body;
if (newSortOrder === undefined || typeof newSortOrder !== 'number') {
return NextResponse.json(
{ error: 'newSortOrder is required and must be a number' },
{ status: 400 }
);
}
// Validate newSortOrder is not negative
if (newSortOrder < 0) {
return NextResponse.json(
{ error: 'newSortOrder must be a non-negative number' },
{ status: 400 }
);
}
// Check if wishlist exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (existingWishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
const oldSortOrder = existingWishlist[0].sortOrder;
// Get all wishlists sorted by sortOrder
const allWishlists = await db
.select()
.from(wishlists)
.orderBy(wishlists.sortOrder);
// Validate newSortOrder is within bounds
if (newSortOrder >= allWishlists.length) {
return NextResponse.json(
{ error: `newSortOrder must be less than ${allWishlists.length}` },
{ status: 400 }
);
}
// Skip if no change needed
if (oldSortOrder === newSortOrder) {
return NextResponse.json({
success: true,
wishlist: existingWishlist[0],
});
}
// Remove the wishlist being moved from the array
const movingWishlistIndex = allWishlists.findIndex(w => w.id === id);
// Safety check: ensure wishlist was found in the array
if (movingWishlistIndex === -1) {
console.error(`Wishlist ${id} not found in wishlists array`);
return NextResponse.json(
{ error: 'Wishlist not found in array' },
{ status: 500 }
);
}
const movingWishlist = allWishlists[movingWishlistIndex];
allWishlists.splice(movingWishlistIndex, 1);
// Insert it at the new position
allWishlists.splice(newSortOrder, 0, movingWishlist);
// Update all sortOrders in a transaction for atomicity
const updatedWishlist = await db.transaction(async (tx) => {
// Update all sortOrders
for (let i = 0; i < allWishlists.length; i++) {
await tx
.update(wishlists)
.set({
sortOrder: i,
updatedAt: new Date(),
})
.where(eq(wishlists.id, allWishlists[i].id));
}
// Get the updated wishlist
const result = await tx
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
return result[0];
});
return NextResponse.json({
success: true,
wishlist: updatedWishlist,
});
} catch (error) {
console.error('Error reordering wishlist:', error);
return NextResponse.json(
{ error: 'Failed to reorder wishlist' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,197 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
wishlist: wishlist[0],
});
} catch (error) {
console.error('Error fetching wishlist:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlist' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { name, slug, description, imageUrl, isPublic } = body;
// Check if wishlist exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (existingWishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// If slug is being changed, check if new slug is available
if (slug && slug !== existingWishlist[0].slug) {
const slugExists = await db
.select()
.from(wishlists)
.where(eq(wishlists.slug, slug))
.limit(1);
if (slugExists.length > 0) {
return NextResponse.json(
{ error: 'A wishlist with this slug already exists' },
{ status: 409 }
);
}
}
// Build update object (only include provided fields)
const updateData: any = {
updatedAt: new Date(),
};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (description !== undefined) updateData.description = description;
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (isPublic !== undefined) updateData.isPublic = isPublic;
// Update wishlist
const updatedWishlist = await db
.update(wishlists)
.set(updateData)
.where(eq(wishlists.id, id))
.returning();
return NextResponse.json({
success: true,
wishlist: updatedWishlist[0],
});
} catch (error) {
console.error('Error updating wishlist:', error);
return NextResponse.json(
{ error: 'Failed to update wishlist' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
// Check if wishlist exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (existingWishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Delete wishlist (cascade will delete items automatically)
await db
.delete(wishlists)
.where(eq(wishlists.id, id));
return NextResponse.json({
success: true,
message: 'Wishlist deleted successfully',
});
} catch (error) {
console.error('Error deleting wishlist:', error);
return NextResponse.json(
{ error: 'Failed to delete wishlist' },
{ status: 500 }
);
}
}

113
app/api/wishlists/route.ts Normal file
View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const allWishlists = await db
.select()
.from(wishlists)
.orderBy(asc(wishlists.sortOrder));
return NextResponse.json({
success: true,
wishlists: allWishlists,
});
} catch (error) {
console.error('Error fetching wishlists:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlists' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const body = await request.json();
const { name, slug, description, imageUrl, isPublic } = body;
// Validation
if (!name || !slug) {
return NextResponse.json(
{ error: 'Name and slug are required' },
{ status: 400 }
);
}
// Check if slug already exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.slug, slug))
.limit(1);
if (existingWishlist.length > 0) {
return NextResponse.json(
{ error: 'A wishlist with this slug already exists' },
{ status: 409 }
);
}
// Create wishlist
const newWishlist = await db
.insert(wishlists)
.values({
name,
slug,
description: description || null,
imageUrl: imageUrl || null,
isPublic: isPublic !== undefined ? isPublic : false,
})
.returning();
return NextResponse.json(
{
success: true,
wishlist: newWishlist[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error creating wishlist:', error);
return NextResponse.json(
{ error: 'Failed to create wishlist' },
{ status: 500 }
);
}
}

62
app/globals.css Normal file
View File

@@ -0,0 +1,62 @@
@import "tailwindcss";
@variant dark (.dark &);
:root {
--background: #ffffff;
--foreground: #171717;
color-scheme: light;
}
:root[data-theme="dark"],
.dark {
--background: #0a0a0a;
--foreground: #ededed;
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Light mode form elements */
:root input,
:root textarea,
:root select {
color: #171717;
background-color: #ffffff;
}
:root input::placeholder,
:root textarea::placeholder {
color: #9ca3af;
}
/* Dark mode form elements */
:root[data-theme="dark"] input,
:root[data-theme="dark"] textarea,
:root[data-theme="dark"] select,
.dark input,
.dark textarea,
.dark select {
color: #ededed;
background-color: #1f2937;
border-color: #374151;
}
:root[data-theme="dark"] input::placeholder,
:root[data-theme="dark"] textarea::placeholder,
.dark input::placeholder,
.dark textarea::placeholder {
color: #6b7280;
}

3
app/icon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="80" font-size="80" font-family="sans-serif">🛍️</text>
</svg>

After

Width:  |  Height:  |  Size: 140 B

53
app/layout.tsx Normal file
View File

@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/lib/auth-context";
import { ThemeProvider } from "@/components/theme-provider";
import { db, settings } from "@/lib/db";
import { eq } from "drizzle-orm";
async function getSettings() {
try {
const allSettings = await db.select().from(settings);
const settingsObj = allSettings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string>);
return {
siteTitle: settingsObj.siteTitle || 'Wishlist',
homepageSubtext: settingsObj.homepageSubtext || 'Browse and explore available wishlists',
};
} catch (error) {
return {
siteTitle: 'Wishlist',
homepageSubtext: 'Browse and explore available wishlists',
};
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSettings();
return {
title: settings.siteTitle,
description: "Self-hosted wishlist application for families",
icons: {
icon: '/icon.svg',
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="font-sans antialiased bg-gray-50 dark:bg-gray-900">
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}

95
app/lock/page.tsx Normal file
View File

@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Header from '@/components/header';
import Footer from '@/components/footer';
export default function LockPage() {
const router = useRouter();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const response = await fetch('/api/lock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (response.ok) {
// Password verified, redirect to home
router.push('/');
router.refresh();
} else {
setError(data.error || 'Incorrect password');
setPassword('');
}
} catch (err) {
setError('Failed to verify password. Please try again.');
console.error('Lock verification error:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title="Password Required"
subtitle="Please enter the password to access this site"
/>
<div className="max-w-md mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
type="password"
id="password"
required
autoFocus
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-lg"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
disabled={isSubmitting}
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-6 py-3 text-lg font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Verifying...' : 'Submit'}
</button>
</form>
</div>
</div>
</div>
<Footer />
</div>
);
}

50
app/not-found.tsx Normal file
View File

@@ -0,0 +1,50 @@
// Root-level not-found page
export default function RootNotFound() {
return (
<html lang="en">
<body>
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'system-ui, sans-serif',
padding: '1rem'
}}>
<div style={{
maxWidth: '28rem',
width: '100%',
textAlign: 'center',
padding: '2rem',
backgroundColor: 'white',
borderRadius: '0.5rem',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ fontSize: '3.75rem', fontWeight: 'bold', color: '#1f2937' }}>404</div>
<h1 style={{ fontSize: '1.5rem', fontWeight: '600', color: '#111827', marginTop: '1rem' }}>
Page Not Found
</h1>
<p style={{ color: '#4b5563', marginTop: '1rem' }}>
The page you are looking for doesn't exist.
</p>
<a
href="/"
style={{
display: 'inline-block',
marginTop: '1.5rem',
padding: '0.75rem 1.5rem',
backgroundColor: '#2563eb',
color: 'white',
borderRadius: '0.5rem',
fontWeight: '600',
textDecoration: 'none'
}}
>
Go Home
</a>
</div>
</div>
</body>
</html>
);
}

107
app/page.tsx Normal file
View File

@@ -0,0 +1,107 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { wishlistsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
import Header from '@/components/header';
import Footer from '@/components/footer';
import PasswordLockGuard from '@/components/password-lock-guard';
import ShareButton from '@/components/share-button';
export default function Home() {
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [settings, setSettings] = useState<Settings>({ siteTitle: 'Wishlist', homepageSubtext: 'Browse and explore available wishlists' });
useEffect(() => {
fetchWishlists();
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const data = await settingsApi.getSettings();
setSettings(data);
} catch (error) {
console.error('Failed to fetch settings:', error);
}
};
const fetchWishlists = async () => {
try {
const data = await wishlistsApi.getAllPublic();
setWishlists(data);
// Item counts removed - requires authentication
} catch (error) {
console.error('Failed to fetch wishlists:', error);
} finally {
setIsLoading(false);
}
};
return (
<PasswordLockGuard>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title={settings.siteTitle}
subtitle={settings.homepageSubtext}
actions={
<ShareButton
title="Check out this wishlist!"
text="I thought you might be interested in this wishlist."
/>
}
/>
{/* Main Content */}
<div className="max-w-4xl mx-auto py-12 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
) : wishlists.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
<p className="text-gray-500 dark:text-gray-400">No public wishlists available yet</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{wishlists.map((wishlist) => (
<Link
key={wishlist.id}
href={`/${wishlist.slug}`}
className="bg-white dark:bg-gray-800 rounded-xl shadow-md hover:shadow-xl transition-all duration-300 hover:scale-105 border border-gray-100 dark:border-gray-700 overflow-hidden"
>
<div className="flex flex-col md:flex-row">
{wishlist.imageUrl && (
<div className="md:w-64 md:flex-shrink-0">
<img
src={wishlist.imageUrl}
alt={wishlist.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
)}
<div className="p-8 flex-1">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{wishlist.name}
</h3>
{wishlist.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
{wishlist.description}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
<Footer />
</div>
</PasswordLockGuard>
);
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
// Upload directory location
const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads');
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
// Await params in Next.js 16+
const { path: pathSegments } = await params;
console.log('Upload request - pathSegments:', pathSegments);
console.log('UPLOAD_DIR:', UPLOAD_DIR);
// Get the file path from the URL
const filePath = path.join(UPLOAD_DIR, ...pathSegments);
console.log('Constructed filePath:', filePath);
// Security: Ensure the resolved path is within UPLOAD_DIR
const resolvedPath = path.resolve(filePath);
const resolvedUploadDir = path.resolve(UPLOAD_DIR);
console.log('Resolved path:', resolvedPath);
console.log('Resolved upload dir:', resolvedUploadDir);
if (!resolvedPath.startsWith(resolvedUploadDir)) {
console.log('Security check failed - path outside upload dir');
return NextResponse.json(
{ error: 'Invalid file path' },
{ status: 403 }
);
}
// Check if file exists
if (!existsSync(resolvedPath)) {
console.log('File not found at:', resolvedPath);
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
);
}
console.log('File exists, serving...');
// Read the file
const fileBuffer = await readFile(resolvedPath);
// Determine content type based on file extension
const ext = path.extname(resolvedPath).toLowerCase();
const contentTypeMap: Record<string, string> = {
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
};
const contentType = contentTypeMap[ext] || 'application/octet-stream';
// Return the file with appropriate headers
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error) {
console.error('Error serving file:', error);
return NextResponse.json(
{ error: 'Failed to serve file' },
{ status: 500 }
);
}
}

92
app/uploads/route.ts Normal file
View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import { existsSync } from 'fs';
import sharp from 'sharp';
// Configuration
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads');
// Resize configuration
const MAX_WIDTH = 800;
const MAX_HEIGHT = 800;
const QUALITY = 85;
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const type = formData.get('type') as string; // 'wishlist' or 'item'
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `Invalid file type. Allowed types: ${ALLOWED_TYPES.join(', ')}` },
{ status: 400 }
);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB` },
{ status: 400 }
);
}
// Create upload directory if it doesn't exist
const typeDir = path.join(UPLOAD_DIR, type === 'wishlist' ? 'wishlists' : 'items');
if (!existsSync(typeDir)) {
// Create directory with 0775 permissions (rwxrwxr-x)
await mkdir(typeDir, { recursive: true, mode: 0o775 });
}
// Generate unique filename (always use .webp for output)
const timestamp = Date.now();
const filename = `${timestamp}-${Math.random().toString(36).substring(7)}.webp`;
const filepath = path.join(typeDir, filename);
// Convert file to buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Resize and optimize image
const processedImage = await sharp(buffer)
.resize(MAX_WIDTH, MAX_HEIGHT, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: QUALITY })
.toBuffer();
// Save processed image with proper permissions (0664 = rw-rw-r--)
// This respects the umask setting and ensures proper group access
await writeFile(filepath, processedImage, { mode: 0o664 });
console.log(`Uploaded file: ${filepath}`);
// Return the public URL (served from /uploads route)
const publicUrl = `/uploads/${type === 'wishlist' ? 'wishlists' : 'items'}/${filename}`;
return NextResponse.json({
success: true,
url: publicUrl,
filename,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,309 @@
'use client';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
import { $getRoot, $insertNodes, $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode, $createLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { HeadingNode, QuoteNode, $createHeadingNode } from '@lexical/rich-text';
import { $setBlocksType } from '@lexical/selection';
import { useEffect, useState } from 'react';
// Toolbar Component
function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const [showLinkModal, setShowLinkModal] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [linkText, setLinkText] = useState('');
const formatBold = () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
};
const formatItalic = () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
};
const formatUnderline = () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
};
const formatHeading = (level: 'h2' | 'h3') => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(level));
}
});
};
const formatBulletList = () => {
editor.dispatchCommand({ type: 'INSERT_UNORDERED_LIST_COMMAND' } as any, undefined);
};
const formatNumberedList = () => {
editor.dispatchCommand({ type: 'INSERT_ORDERED_LIST_COMMAND' } as any, undefined);
};
const openLinkModal = () => {
// Get selected text if any
editor.getEditorState().read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const text = selection.getTextContent();
setLinkText(text);
}
});
setLinkUrl('');
setShowLinkModal(true);
};
const insertLink = () => {
if (!linkUrl) return;
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// If there's selected text, convert it to a link
if (selection.getTextContent()) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
} else if (linkText) {
// If no selection but we have link text, insert new link
const linkNode = $createLinkNode(linkUrl);
linkNode.append($getRoot().getFirstChild() as any);
selection.insertNodes([linkNode]);
}
}
});
setShowLinkModal(false);
setLinkUrl('');
setLinkText('');
};
return (
<>
<div className="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-t-lg flex-wrap">
<button
type="button"
onClick={formatBold}
className="px-3 py-1.5 text-sm font-bold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Bold"
>
B
</button>
<button
type="button"
onClick={formatItalic}
className="px-3 py-1.5 text-sm italic hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Italic"
>
I
</button>
<button
type="button"
onClick={formatUnderline}
className="px-3 py-1.5 text-sm underline hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Underline"
>
U
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
type="button"
onClick={() => formatHeading('h2')}
className="px-3 py-1.5 text-sm font-bold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Heading 2"
>
H2
</button>
<button
type="button"
onClick={() => formatHeading('h3')}
className="px-3 py-1.5 text-sm font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Heading 3"
>
H3
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
type="button"
onClick={formatBulletList}
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Bullet List"
>
List
</button>
<button
type="button"
onClick={formatNumberedList}
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Numbered List"
>
1. List
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
type="button"
onClick={openLinkModal}
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Insert Link"
>
🔗 Link
</button>
</div>
{/* Link Modal */}
{showLinkModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={() => setShowLinkModal(false)}>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Insert Link</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Link Text {!linkText && '(optional)'}
</label>
<input
type="text"
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
placeholder="Click here"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
URL *
</label>
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
autoFocus
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowLinkModal(false)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={insertLink}
disabled={!linkUrl}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Insert Link
</button>
</div>
</div>
</div>
)}
</>
);
}
// Plugin to load initial HTML content
function InitialContentPlugin({ html }: { html?: string }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (html) {
editor.update(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(html, 'text/html');
const nodes = $generateNodesFromDOM(editor, dom);
$getRoot().clear();
$getRoot().select();
$insertNodes(nodes);
});
}
}, []); // Only run once on mount
return null;
}
interface RichTextEditorProps {
value?: string;
onChange: (html: string) => void;
placeholder?: string;
}
export default function RichTextEditor({ value, onChange, placeholder = 'Enter text...' }: RichTextEditorProps) {
const initialConfig = {
namespace: 'WishlistPreferences',
theme: {
paragraph: 'mb-2',
heading: {
h2: 'text-2xl font-bold mb-3 mt-4 text-gray-900 dark:text-white',
h3: 'text-xl font-semibold mb-2 mt-3 text-gray-900 dark:text-white',
},
list: {
ul: 'list-disc list-inside mb-2',
ol: 'list-decimal list-inside mb-2',
},
link: 'text-indigo-600 dark:text-indigo-400 hover:underline',
text: {
bold: 'font-bold',
italic: 'italic',
underline: 'underline',
},
},
onError: (error: Error) => {
console.error(error);
},
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
LinkNode,
AutoLinkNode,
],
};
const handleChange = (editorState: any, editor: any) => {
editor.read(() => {
const html = $generateHtmlFromNodes(editor);
onChange(html);
});
};
return (
<LexicalComposer initialConfig={initialConfig}>
<div className="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<ToolbarPlugin />
<RichTextPlugin
contentEditable={
<ContentEditable className="min-h-[150px] p-4 outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
}
placeholder={
<div className="absolute top-14 left-4 text-gray-400 pointer-events-none">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<LinkPlugin />
<OnChangePlugin onChange={handleChange} />
{value && <InitialContentPlugin html={value} />}
</div>
</LexicalComposer>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import { useState } from 'react';
import ImageUpload from '@/components/image-upload';
import RichTextEditor from '@/components/RichTextEditor';
interface WishlistFormData {
name: string;
slug: string;
description: string;
preferences: string;
imageUrl: string;
isPublic: boolean;
}
interface CreateWishlistModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (data: WishlistFormData) => Promise<void>;
error?: string;
}
export default function CreateWishlistModal({
isOpen,
onClose,
onCreate,
error,
}: CreateWishlistModalProps) {
const [formData, setFormData] = useState<WishlistFormData>({
name: '',
slug: '',
description: '',
preferences: '',
imageUrl: '',
isPublic: true,
});
const [isCreating, setIsCreating] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const handleNameChange = (name: string) => {
setFormData((prev) => ({
...prev,
name,
slug: name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsCreating(true);
try {
await onCreate(formData);
setFormData({ name: '', slug: '', description: '', preferences: '', imageUrl: '', isPublic: true });
} finally {
setIsCreating(false);
}
};
const handleClose = () => {
setFormData({ name: '', slug: '', description: '', preferences: '', imageUrl: '', isPublic: true });
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-80 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 shadow-2xl">
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">
Create New Wishlist
</h2>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-1">
Name *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 dark:text-white dark:bg-gray-700"
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-1">
Slug (URL-friendly)
</label>
<input
type="text"
required
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 dark:text-white dark:bg-gray-700"
value={formData.slug}
onChange={(e) =>
setFormData((prev) => ({ ...prev, slug: e.target.value }))
}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-1">
Description
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 dark:text-white dark:bg-gray-700"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({
...prev,
description: e.target.value,
}))
}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-2">
General Interests & Preferences
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Share general things you like - hobbies, styles, colors, brands, etc. This appears before your wishlist items.
</p>
<RichTextEditor
value={formData.preferences}
onChange={(html) => setFormData((prev) => ({ ...prev, preferences: html }))}
placeholder="e.g., I love anything purple, enjoy sci-fi books, prefer sustainable brands..."
/>
</div>
<ImageUpload
currentImageUrl={formData.imageUrl}
onImageChange={(url) => setFormData((prev) => ({ ...prev, imageUrl: url }))}
onUploadStateChange={setIsImageUploading}
type="wishlist"
label="Wishlist Image"
/>
<div className="flex items-center">
<input
type="checkbox"
id="isPublic"
className="h-5 w-5 text-indigo-600 border-2 border-gray-300 rounded focus:ring-2 focus:ring-indigo-500"
checked={formData.isPublic}
onChange={(e) =>
setFormData((prev) => ({
...prev,
isPublic: e.target.checked,
}))
}
/>
<label
htmlFor="isPublic"
className="ml-2 block text-base font-medium text-gray-900 dark:text-gray-200"
>
Make Public
</label>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
className="px-6 py-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg text-base font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating || isImageUploading}
className="px-6 py-3 border border-transparent rounded-lg text-base font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImageUploading ? 'Uploading...' : isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { type Item } from '@/lib/api';
interface ItemCardProps {
item: Item;
onEdit: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}
export default function ItemCard({
item,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: ItemCardProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="flex items-stretch">
{/* Arrow buttons on the left */}
<div className="flex flex-col w-12 border-r border-gray-200 dark:border-gray-700">
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
disabled={isFirst}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
disabled={isLast}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div className="flex-1 p-4 flex gap-3">
{/* Image on Left */}
{item.imageUrl && (
<img
src={item.imageUrl}
alt={item.name}
className="w-16 h-16 object-cover rounded border border-gray-200 dark:border-gray-600 flex-shrink-0"
/>
)}
<div className="flex-1">
<h5 className="text-base font-semibold text-gray-900 dark:text-white">
{item.name}
</h5>
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-400 mt-1">
{item.description}
</p>
)}
{item.price && (
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
${item.price.toFixed(2)} {item.currency}
</p>
)}
</div>
</div>
<div className="flex flex-col w-16 border-l border-gray-200 dark:border-gray-700">
<button
onClick={onEdit}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
title="Edit item"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={onDelete}
className="flex-1 flex items-center justify-center text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer"
title="Delete item"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import ImageUpload from '@/components/image-upload';
import PurchaseUrlFields from './PurchaseUrlFields';
import { type Item } from '@/lib/api';
interface ItemFormProps {
item?: Partial<Item>;
onSubmit: (item: Partial<Item>) => Promise<void>;
onCancel: () => void;
mode: 'create' | 'edit';
error?: string;
}
export default function ItemForm({ item, onSubmit, onCancel, mode, error }: ItemFormProps) {
const [formData, setFormData] = useState<Partial<Item>>(
item || {
name: '',
description: '',
price: null,
currency: 'USD',
quantity: 1,
imageUrl: '',
purchaseUrls: [],
}
);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
try {
await onSubmit(formData);
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="mb-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<h5 className="text-base font-medium text-gray-900 dark:text-white mb-3">
{mode === 'create' ? 'Add New Item' : 'Edit Item'}
</h5>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Name *
</label>
<input
type="text"
required
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Price
</label>
<input
type="number"
step="0.01"
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.price || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price: e.target.value ? parseFloat(e.target.value) : null,
}))
}
/>
</div>
<div className="md:col-span-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
rows={2}
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.description || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
/>
</div>
<div className="md:col-span-2">
<ImageUpload
currentImageUrl={formData.imageUrl || ''}
onImageChange={(url) =>
setFormData((prev) => ({ ...prev, imageUrl: url }))
}
onUploadStateChange={setIsImageUploading}
type="item"
label="Item Image"
/>
</div>
<div className="md:col-span-2">
<PurchaseUrlFields
purchaseUrls={formData.purchaseUrls || []}
onChange={(urls) =>
setFormData((prev) => ({ ...prev, purchaseUrls: urls }))
}
/>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isImageUploading || isSubmitting}
className="px-4 py-2 text-base bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImageUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : mode === 'create' ? 'Add Item' : 'Save'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,70 @@
interface PurchaseUrl {
label: string;
url: string;
}
interface PurchaseUrlFieldsProps {
purchaseUrls: PurchaseUrl[];
onChange: (urls: PurchaseUrl[]) => void;
}
export default function PurchaseUrlFields({ purchaseUrls, onChange }: PurchaseUrlFieldsProps) {
const handleAdd = () => {
onChange([...purchaseUrls, { label: '', url: '' }]);
};
const handleRemove = (index: number) => {
onChange(purchaseUrls.filter((_, i) => i !== index));
};
const handleUpdate = (index: number, field: 'label' | 'url', value: string) => {
const updated = [...purchaseUrls];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
return (
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Purchase URLs
</label>
<div className="space-y-2">
{purchaseUrls.map((urlObj, index) => (
<div key={index} className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-start">
<input
type="text"
placeholder="Label (e.g., Amazon)"
value={urlObj.label}
onChange={(e) => handleUpdate(index, 'label', e.target.value)}
className="w-full sm:w-1/3 px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<input
type="url"
placeholder="https://example.com"
value={urlObj.url}
onChange={(e) => handleUpdate(index, 'url', e.target.value)}
className="flex-1 px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<button
type="button"
onClick={() => handleRemove(index)}
className="px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded cursor-pointer"
title="Remove URL"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button
type="button"
onClick={handleAdd}
className="w-full px-3 py-2 text-base border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors cursor-pointer"
>
+ Add URL
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { type Settings } from '@/lib/api';
interface SettingsSectionProps {
settings: Settings;
onUpdate: (settings: Settings) => Promise<void>;
}
export default function SettingsSection({ settings, onUpdate }: SettingsSectionProps) {
const [editingSettings, setEditingSettings] = useState(false);
const [settingsForm, setSettingsForm] = useState<Settings>(settings);
const [settingsError, setSettingsError] = useState('');
const startEditingSettings = () => {
setEditingSettings(true);
setSettingsForm({ ...settings });
setSettingsError('');
};
const cancelEditingSettings = () => {
setEditingSettings(false);
setSettingsError('');
};
const handleUpdateSettings = async (e: React.FormEvent) => {
e.preventDefault();
setSettingsError('');
try {
await onUpdate(settingsForm);
setEditingSettings(false);
} catch (error: any) {
setSettingsError(error.message || 'Failed to update settings');
}
};
return (
<div className="mb-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Settings
</h2>
{!editingSettings && (
<button
onClick={startEditingSettings}
className="px-4 py-2 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer"
>
Edit Settings
</button>
)}
</div>
</div>
<div className="p-5">
{settingsError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{settingsError}
</div>
)}
{editingSettings ? (
<form onSubmit={handleUpdateSettings} className="space-y-4">
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Title
</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.siteTitle}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, siteTitle: e.target.value }))
}
placeholder="Wishlist"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
This is used for the page title and homepage header
</p>
</div>
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Homepage Subtext
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.homepageSubtext}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, homepageSubtext: e.target.value }))
}
placeholder="Browse and explore available wishlists"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
This appears below the title on the homepage
</p>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center mb-3">
<input
type="checkbox"
id="passwordLockEnabled"
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
checked={settingsForm.passwordLockEnabled}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, passwordLockEnabled: e.target.checked }))
}
/>
<label htmlFor="passwordLockEnabled" className="ml-2 block text-base font-medium text-gray-700 dark:text-gray-300">
Enable Password Lock
</label>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
When enabled, visitors must enter a password to access the website
</p>
{settingsForm.passwordLockEnabled && (
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Password
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.passwordLock || ''}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, passwordLock: e.target.value }))
}
placeholder="Enter password (leave blank to keep current)"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Leave blank to keep the current password unchanged
</p>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={cancelEditingSettings}
className="px-4 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-base bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 cursor-pointer"
>
Save Settings
</button>
</div>
</form>
) : (
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Site Title</p>
<p className="text-base text-gray-900 dark:text-white">{settings.siteTitle}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Homepage Subtext</p>
<p className="text-base text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Password Lock</p>
<p className="text-base text-gray-900 dark:text-white">
{settings.passwordLockEnabled ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
Enabled
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">
Disabled
</span>
)}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
interface Stats {
totalWishlists: number;
publicWishlists: number;
totalItems: number;
}
interface StatsGridProps {
stats: Stats;
}
export default function StatsGrid({ stats }: StatsGridProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 mb-8">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 text-3xl">📋</div>
<div className="ml-4 w-0 flex-1">
<dl>
<dt className="text-base font-medium text-gray-500 dark:text-gray-400 truncate">
Total Wishlists
</dt>
<dd className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalWishlists}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 text-3xl">🌐</div>
<div className="ml-4 w-0 flex-1">
<dl>
<dt className="text-base font-medium text-gray-500 dark:text-gray-400 truncate">
Public Wishlists
</dt>
<dd className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.publicWishlists}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 text-3xl">🎁</div>
<div className="ml-4 w-0 flex-1">
<dl>
<dt className="text-base font-medium text-gray-500 dark:text-gray-400 truncate">
Total Items
</dt>
<dd className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalItems}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,482 @@
'use client';
import { useState } from 'react';
import { type Wishlist, type Item, itemsApi } from '@/lib/api';
import ImageUpload from '@/components/image-upload';
import RichTextEditor from '@/components/RichTextEditor';
import ItemCard from './ItemCard';
import ItemForm from './ItemForm';
interface WishlistCardProps {
wishlist: Wishlist;
itemCount: number;
onUpdate: (id: string, data: Partial<Wishlist>) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onMoveUp: (id: string) => Promise<void>;
onMoveDown: (id: string) => Promise<void>;
isFirst: boolean;
isLast: boolean;
onItemsChange: () => void;
}
export default function WishlistCard({
wishlist,
itemCount,
onUpdate,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
onItemsChange,
}: WishlistCardProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
name: '',
slug: '',
description: '',
preferences: '',
imageUrl: '',
isPublic: true,
});
const [editError, setEditError] = useState('');
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
const [expandedWishlistId, setExpandedWishlistId] = useState<string | null>(null);
const [wishlistItems, setWishlistItems] = useState<Item[]>([]);
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [showAddItemForm, setShowAddItemForm] = useState(false);
const [newItemError, setNewItemError] = useState<string>('');
const startEditing = () => {
setEditingId(wishlist.id);
setEditForm({
name: wishlist.name,
slug: wishlist.slug,
description: wishlist.description || '',
preferences: wishlist.preferences || '',
imageUrl: wishlist.imageUrl || '',
isPublic: wishlist.isPublic,
});
setEditError('');
};
const cancelEditing = () => {
setEditingId(null);
setEditError('');
};
const handleEditNameChange = (name: string) => {
setEditForm((prev) => ({
...prev,
name,
slug: name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}));
};
const handleUpdateWishlist = async (e: React.FormEvent) => {
e.preventDefault();
setEditError('');
try {
await onUpdate(wishlist.id, editForm);
setEditingId(null);
} catch (error: any) {
setEditError(error.message || 'Failed to update wishlist');
}
};
const toggleWishlistExpand = async () => {
if (expandedWishlistId === wishlist.id) {
setExpandedWishlistId(null);
} else {
setExpandedWishlistId(wishlist.id);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
}
};
const handleCreateItem = async (itemData: Partial<Item>) => {
setNewItemError('');
try {
await itemsApi.create(wishlist.id, itemData);
setShowAddItemForm(false);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
onItemsChange();
} catch (error: any) {
setNewItemError(error.message || 'Failed to create item');
throw error;
}
};
const handleUpdateItem = async (itemData: Partial<Item>) => {
if (!editingItemId) return;
try {
await itemsApi.update(editingItemId, itemData);
setEditingItemId(null);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
onItemsChange();
} catch (error: any) {
alert(error.message || 'Failed to update item');
throw error;
}
};
const handleDeleteItem = async (itemId: string) => {
if (!confirm('Are you sure you want to delete this item?')) return;
try {
await itemsApi.delete(itemId);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
onItemsChange();
} catch (error) {
alert('Failed to delete item');
}
};
const handleMoveItemUp = async (itemId: string) => {
const currentIndex = wishlistItems.findIndex((item) => item.id === itemId);
if (currentIndex <= 0) return;
try {
await itemsApi.reorder(itemId, currentIndex - 1);
const updatedItems = await itemsApi.getAll(wishlist.id);
setWishlistItems(updatedItems);
} catch (error: any) {
alert(error?.message || 'Failed to reorder item');
}
};
const handleMoveItemDown = async (itemId: string) => {
const currentIndex = wishlistItems.findIndex((item) => item.id === itemId);
if (currentIndex === -1 || currentIndex === wishlistItems.length - 1) return;
try {
await itemsApi.reorder(itemId, currentIndex + 1);
const updatedItems = await itemsApi.getAll(wishlist.id);
setWishlistItems(updatedItems);
} catch (error: any) {
alert(error?.message || 'Failed to reorder item');
}
};
const editingItem = wishlistItems.find((item) => item.id === editingItemId);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-5 border-b border-gray-200 dark:border-gray-700">
{editError && (
<div className="mb-3 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{editError}
</div>
)}
<div className="flex items-stretch -m-5">
{/* Arrow buttons on the left */}
<div className="flex flex-col w-12 border-r border-gray-200 dark:border-gray-700">
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp(wishlist.id);
}}
disabled={isFirst}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown(wishlist.id);
}}
disabled={isLast}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div className="flex-1 p-5">
{editingId === wishlist.id ? (
// Inline Edit Mode
<div className="space-y-3">
<ImageUpload
currentImageUrl={editForm.imageUrl}
onImageChange={(url) =>
setEditForm((prev) => ({ ...prev, imageUrl: url }))
}
onUploadStateChange={setIsWishlistImageUploading}
type="wishlist"
label="Wishlist Image"
/>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Wishlist Name *
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
className="text-lg font-bold px-2 py-1 border-2 border-indigo-500 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white flex-1"
placeholder="Wishlist name"
/>
<label className="flex items-center gap-2 text-base">
<input
type="checkbox"
checked={editForm.isPublic}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
isPublic: e.target.checked,
}))
}
className="h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
<span className="text-gray-700 dark:text-gray-300">Public</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
URL Slug *
</label>
<input
type="text"
value={editForm.slug}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, slug: e.target.value }))
}
className="text-base px-2 py-1 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white w-full"
placeholder="url-slug"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={editForm.description}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
description: e.target.value,
}))
}
className="text-base px-2 py-1 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white w-full"
placeholder="Description"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Preferences
</label>
<RichTextEditor
value={editForm.preferences}
onChange={(html) => setEditForm((prev) => ({ ...prev, preferences: html }))}
placeholder="General interests and preferences..."
/>
</div>
<p className="text-base text-gray-500 dark:text-gray-500">
{itemCount} items
</p>
</div>
</div>
) : (
// Display Mode
<div className="flex items-start gap-3">
{wishlist.imageUrl && (
<img
src={wishlist.imageUrl}
alt={wishlist.name}
className="w-20 h-20 object-cover rounded border border-gray-200 dark:border-gray-600"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{wishlist.name}
</h3>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium ${
wishlist.isPublic
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
}`}
>
{wishlist.isPublic ? 'Public' : 'Private'}
</span>
</div>
<p className="text-base text-gray-600 dark:text-gray-400 mb-1">
/{wishlist.slug}
</p>
{wishlist.description && (
<p className="text-base text-gray-600 dark:text-gray-400 mb-1">
{wishlist.description}
</p>
)}
<p className="text-base text-gray-500 dark:text-gray-500">
{itemCount} items
</p>
</div>
</div>
)}
</div>
<div className="flex flex-col w-24 border-l border-gray-200 dark:border-gray-700">
{editingId === wishlist.id ? (
<>
<button
onClick={cancelEditing}
className="flex-1 flex items-center justify-center text-base font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700"
title="Cancel"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
handleUpdateWishlist(e as any);
}}
disabled={isWishlistImageUploading}
className="flex-1 flex items-center justify-center text-base font-semibold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={isWishlistImageUploading ? "Uploading..." : "Save"}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
</>
) : (
<>
<button
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
title="Edit wishlist"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(wishlist.id);
}}
className="flex-1 flex items-center justify-center text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer"
title="Delete wishlist"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
</div>
</div>
</div>
{/* Expand/Collapse Row */}
<button
onClick={toggleWishlistExpand}
className="w-full px-5 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-center gap-2 text-base font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
>
{expandedWishlistId === wishlist.id ? (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span>Hide Items</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span>Show Items ({itemCount})</span>
</>
)}
</button>
{/* Items Section */}
{expandedWishlistId === wishlist.id && (
<div className="p-5 bg-gray-50 dark:bg-gray-900/50">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
Items ({wishlistItems.length})
</h4>
<button
onClick={() => {
setShowAddItemForm(true);
setNewItemError('');
}}
className="px-4 py-2 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer"
>
+ Add Item
</button>
</div>
{/* Add Item Form */}
{showAddItemForm && (
<ItemForm
mode="create"
onSubmit={handleCreateItem}
onCancel={() => {
setShowAddItemForm(false);
setNewItemError('');
}}
error={newItemError}
/>
)}
{/* Items List */}
{wishlistItems.length === 0 ? (
<p className="text-base text-gray-500 dark:text-gray-400 text-center py-4">
No items yet
</p>
) : (
<div className="space-y-3">
{wishlistItems.map((item, index) => (
<div key={item.id}>
{editingItemId === item.id ? (
<ItemForm
mode="edit"
item={editingItem}
onSubmit={handleUpdateItem}
onCancel={() => setEditingItemId(null)}
/>
) : (
<ItemCard
item={item}
onEdit={() => setEditingItemId(item.id)}
onDelete={() => handleDeleteItem(item.id)}
onMoveUp={() => handleMoveItemUp(item.id)}
onMoveDown={() => handleMoveItemDown(item.id)}
isFirst={index === 0}
isLast={index === wishlistItems.length - 1}
/>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

94
components/footer.tsx Normal file
View File

@@ -0,0 +1,94 @@
'use client';
import Link from 'next/link';
import { useTheme } from '@/components/theme-provider';
import { useAuth } from '@/lib/auth-context';
export default function Footer() {
const { theme, toggleTheme } = useTheme();
const { isAuthenticated } = useAuth();
return (
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col items-center gap-2 text-sm">
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
<p>Built for families</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/login"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
Admin
</Link>
{isAuthenticated && (
<>
<span className="text-gray-400"></span>
<a
href="https://github.com/Reggio-Digital/wishlist"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="GitHub Repository"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
</a>
</>
)}
<span className="text-gray-400"></span>
<button
onClick={toggleTheme}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300 font-medium"
aria-label="Toggle theme"
>
{theme === 'light' ? (
<>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span>Dark Mode</span>
</>
) : (
<>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span>Light Mode</span>
</>
)}
</button>
</div>
</div>
</div>
);
}

62
components/header.tsx Normal file
View File

@@ -0,0 +1,62 @@
'use client';
import { useAuth } from '@/lib/auth-context';
interface HeaderProps {
title: string;
subtitle?: string;
imageUrl?: string;
actions?: React.ReactNode;
maxWidth?: 'max-w-5xl' | 'max-w-7xl';
}
export default function Header({ title, subtitle, imageUrl, actions, maxWidth = 'max-w-7xl' }: HeaderProps) {
const { isAuthenticated } = useAuth();
return (
<>
{/* Admin Warning */}
{isAuthenticated && (
<div className="sticky top-0 z-50 bg-yellow-50 dark:bg-yellow-900 border-b border-yellow-200 dark:border-yellow-800">
<div className={`${maxWidth} mx-auto py-3 px-4 sm:px-6 lg:px-8`}>
<p className="text-center text-sm text-yellow-800 dark:text-yellow-200">
Warning: Viewing public wishlists may spoil surprises
</p>
</div>
</div>
)}
{/* Hero Section */}
<div className="bg-white dark:bg-gray-800 shadow-sm">
<div className={`${maxWidth} mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8`}>
<div className="flex flex-col md:flex-row items-center md:items-start gap-8">
{imageUrl && (
<div className="md:w-64 flex-shrink-0">
<img
src={imageUrl}
alt={title}
className="w-64 h-64 object-cover rounded-lg shadow-lg"
/>
</div>
)}
<div className={imageUrl ? 'flex-1 text-left' : 'flex-1 text-center'}>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h1>
{subtitle && (
<p className={`text-xl sm:text-2xl text-gray-600 dark:text-gray-300 mb-6 ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
{subtitle}
</p>
)}
{actions && (
<div className={`flex flex-col sm:flex-row items-center gap-3 ${imageUrl ? 'justify-start' : 'justify-center'}`}>
{actions}
</div>
)}
</div>
</div>
</div>
</div>
</>
);
}

302
components/image-upload.tsx Normal file
View File

@@ -0,0 +1,302 @@
'use client';
import { useState, useRef, useEffect } from 'react';
interface ImageUploadProps {
currentImageUrl?: string;
onImageChange: (url: string) => void;
type: 'wishlist' | 'item';
label?: string;
onUploadStateChange?: (isUploading: boolean) => void;
}
export default function ImageUpload({
currentImageUrl,
onImageChange,
type,
label = 'Image',
onUploadStateChange,
}: ImageUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState('');
const [imageUrl, setImageUrl] = useState(currentImageUrl || '');
const [useUrl, setUseUrl] = useState(!!currentImageUrl);
const fileInputRef = useRef<HTMLInputElement>(null);
const pasteAreaRef = useRef<HTMLDivElement>(null);
const uploadFile = (file: File) => {
// Client-side validation
const maxSize = 5 * 1024 * 1024; // 5MB
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
setUploadError('Invalid file type. Please upload a JPEG, PNG, WebP, or GIF image.');
return;
}
if (file.size > maxSize) {
setUploadError('File is too large. Maximum size is 5MB.');
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadError('');
onUploadStateChange?.(true);
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
setUploadProgress(percentComplete);
}
});
// Handle completion
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
setImageUrl(data.url);
onImageChange(data.url);
setUploadProgress(100);
} catch (error) {
console.error('Failed to parse response:', error);
setUploadError('Failed to parse server response');
}
} else {
try {
const data = JSON.parse(xhr.responseText);
setUploadError(data.error || 'Upload failed');
} catch {
setUploadError(`Upload failed with status ${xhr.status}`);
}
}
setIsUploading(false);
onUploadStateChange?.(false);
});
// Handle errors
xhr.addEventListener('error', () => {
console.error('Upload error');
setUploadError('Network error occurred during upload');
setIsUploading(false);
onUploadStateChange?.(false);
});
// Handle abort
xhr.addEventListener('abort', () => {
setUploadError('Upload was cancelled');
setIsUploading(false);
onUploadStateChange?.(false);
});
xhr.open('POST', '/uploads');
xhr.send(formData);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
uploadFile(file);
};
const handlePaste = (e: ClipboardEvent) => {
// Only handle paste if we're in upload mode (not URL mode)
if (useUrl || imageUrl) return;
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Check if the item is an image
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
uploadFile(file);
}
break;
}
}
};
// Add paste event listener
useEffect(() => {
const handlePasteEvent = (e: Event) => handlePaste(e as ClipboardEvent);
// Listen for paste events on the component's container
const pasteArea = pasteAreaRef.current;
if (pasteArea) {
pasteArea.addEventListener('paste', handlePasteEvent);
}
// Also listen globally when in upload mode and no image is set
if (!useUrl && !imageUrl) {
document.addEventListener('paste', handlePasteEvent);
}
return () => {
if (pasteArea) {
pasteArea.removeEventListener('paste', handlePasteEvent);
}
document.removeEventListener('paste', handlePasteEvent);
};
}, [useUrl, imageUrl]);
const handleUrlChange = (url: string) => {
setImageUrl(url);
onImageChange(url);
};
const handleRemoveImage = () => {
setImageUrl('');
onImageChange('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div ref={pasteAreaRef} className="space-y-3" tabIndex={-1}>
{label && (
<label className="flex items-center gap-2 text-base font-medium text-gray-700 dark:text-gray-300">
{label}
<div className="group relative inline-block">
<svg
className="w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 cursor-help transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="Upload information"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-opacity absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg whitespace-nowrap z-10 pointer-events-none">
Max 5MB. Allowed: JPEG, PNG, WebP, GIF. Images will be resized to max 800x800px and optimized.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
</div>
</label>
)}
{imageUrl ? (
/* Image Preview with Remove Button */
<div className="relative inline-block">
<img
src={imageUrl}
alt="Preview"
className="w-full h-32 object-cover rounded-lg border-2 border-gray-300 dark:border-gray-600"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
<button
type="button"
onClick={handleRemoveImage}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg transition-colors"
title="Remove image"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<>
{/* Toggle between URL and File Upload */}
<div className="grid grid-cols-2 gap-2 mb-3">
<button
type="button"
onClick={() => setUseUrl(true)}
className={`px-3 py-2 text-base rounded cursor-pointer transition-colors ${
useUrl
? 'bg-indigo-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Use URL
</button>
<button
type="button"
onClick={() => setUseUrl(false)}
className={`px-3 py-2 text-base rounded cursor-pointer transition-colors ${
!useUrl
? 'bg-indigo-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Upload File
</button>
</div>
{useUrl ? (
/* URL Input */
<div>
<input
type="url"
placeholder="https://example.com/image.jpg"
className="w-full px-3 py-2 text-base border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
value={imageUrl}
onChange={(e) => handleUrlChange(e.target.value)}
/>
</div>
) : (
/* File Upload */
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
onChange={handleFileUpload}
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent dark:bg-gray-700 dark:text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
disabled={isUploading}
/>
<p className="mt-1 text-sm text-indigo-600 dark:text-indigo-400 font-medium">
💡 Tip: You can also paste an image directly (Ctrl+V / Cmd+V)
</p>
</div>
)}
{/* Error Message */}
{uploadError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-base">
{uploadError}
</div>
)}
{/* Upload Progress Bar */}
{isUploading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
<span>Uploading...</span>
<span className="font-medium">{Math.round(uploadProgress)}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
<div
className="bg-indigo-600 h-2.5 rounded-full transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
</div>
)}
</>
)}
</div>
);
}

265
components/item-form.tsx Normal file
View File

@@ -0,0 +1,265 @@
'use client';
import { useState } from 'react';
import { scrapingApi, type Item } from '@/lib/api';
import ImageUpload from './image-upload';
interface ItemFormProps {
initialData?: Partial<Item>;
onSubmit: (data: Partial<Item>) => Promise<void>;
onCancel: () => void;
isEditing?: boolean;
}
export default function ItemForm({ initialData, onSubmit, onCancel, isEditing = false }: ItemFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
description: initialData?.description || '',
price: initialData?.price?.toString() || '',
currency: initialData?.currency || 'USD',
quantity: initialData?.quantity?.toString() || '1',
imageUrl: initialData?.imageUrl || '',
purchaseUrl: initialData?.purchaseUrls?.[0]?.url || '',
purchaseLabel: initialData?.purchaseUrls?.[0]?.label || '',
});
const [scrapeUrl, setScrapeUrl] = useState('');
const [isScraping, setIsScraping] = useState(false);
const [scrapeError, setScrapeError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const handleScrape = async () => {
if (!scrapeUrl) return;
setIsScraping(true);
setScrapeError('');
try {
const data = await scrapingApi.scrapeUrl(scrapeUrl);
setFormData((prev) => ({
...prev,
name: data.title || prev.name,
description: data.description || prev.description,
price: data.price?.toString() || prev.price,
currency: data.currency || prev.currency,
imageUrl: data.imageUrl || prev.imageUrl,
purchaseUrl: scrapeUrl,
purchaseLabel: new URL(scrapeUrl).hostname.replace('www.', ''),
}));
} catch (error: any) {
setScrapeError(error.message || 'Failed to scrape URL');
} finally {
setIsScraping(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
setSubmitError('');
setIsSubmitting(true);
try {
const purchaseUrls = formData.purchaseUrl
? [
{
url: formData.purchaseUrl,
label: formData.purchaseLabel || 'Link',
},
]
: null;
await onSubmit({
name: formData.name,
description: formData.description || null,
price: formData.price ? parseFloat(formData.price) : null,
currency: formData.currency,
quantity: parseInt(formData.quantity) || 1,
imageUrl: formData.imageUrl || null,
purchaseUrls,
});
} catch (error: any) {
setSubmitError(error.message || 'Failed to save item');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{submitError && (
<div className="p-4 bg-red-50 text-red-800 rounded-md text-sm">
{submitError}
</div>
)}
{/* URL Scraper */}
{!isEditing && (
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-700 mb-2">
Auto-fill from URL (optional)
</label>
<div className="flex space-x-2">
<input
type="url"
placeholder="https://example.com"
className="flex-1 px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={scrapeUrl}
onChange={(e) => setScrapeUrl(e.target.value)}
/>
<button
type="button"
onClick={handleScrape}
disabled={isScraping || !scrapeUrl}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 font-semibold transition-colors"
>
{isScraping ? 'Scraping...' : 'Scrape'}
</button>
</div>
{scrapeError && (
<p className="mt-2 text-sm text-red-600">{scrapeError}</p>
)}
<p className="mt-2 text-xs text-gray-600">
Supports common retailers like Amazon, eBay, etc.
</p>
</div>
)}
{/* Basic Info */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Item Name *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
rows={4}
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
/>
</div>
{/* Price & Quantity */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Price
</label>
<input
type="number"
step="0.01"
min="0"
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={formData.price}
onChange={(e) => {
const value = e.target.value;
// Only allow empty string or valid numbers (including decimals)
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setFormData((prev) => ({ ...prev, price: value }));
}
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Currency
</label>
<select
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={formData.currency}
onChange={(e) =>
setFormData((prev) => ({ ...prev, currency: e.target.value }))
}
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="CAD">CAD</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Quantity
</label>
<input
type="number"
min="1"
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={formData.quantity}
onChange={(e) =>
setFormData((prev) => ({ ...prev, quantity: e.target.value }))
}
/>
</div>
{/* Image Upload/URL */}
<ImageUpload
currentImageUrl={formData.imageUrl}
onImageChange={(url) => setFormData((prev) => ({ ...prev, imageUrl: url }))}
type="item"
label="Product Image"
/>
{/* Purchase Link */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Purchase Link
</label>
<input
type="url"
placeholder="https://example.com/product"
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 mb-2"
value={formData.purchaseUrl}
onChange={(e) =>
setFormData((prev) => ({ ...prev, purchaseUrl: e.target.value }))
}
/>
<input
type="text"
placeholder="Link Label"
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
value={formData.purchaseLabel}
onChange={(e) =>
setFormData((prev) => ({ ...prev, purchaseLabel: e.target.value }))
}
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="px-6 py-3 border-2 border-gray-300 rounded-lg text-base font-semibold text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 border border-transparent rounded-lg text-base font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : isEditing ? 'Update Item' : 'Create Item'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export default function PasswordLockGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [isChecking, setIsChecking] = useState(true);
const [isLocked, setIsLocked] = useState(false);
useEffect(() => {
// Skip check for admin and lock pages
if (pathname.startsWith('/admin') || pathname === '/lock') {
setIsChecking(false);
return;
}
const checkPasswordLock = async () => {
try {
const response = await fetch('/api/settings');
const data = await response.json();
if (data.success && data.settings.passwordLockEnabled) {
setIsLocked(true);
// Redirect to lock page
router.push('/lock');
} else {
setIsChecking(false);
}
} catch (error) {
console.error('Error checking password lock:', error);
// On error, allow access to prevent site lockout
setIsChecking(false);
}
};
checkPasswordLock();
}, [pathname, router]);
// Show loading or nothing while checking
if (isChecking) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
// If locked, don't render children (redirect is happening)
if (isLocked) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,30 @@
'use client';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/admin/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
interface ShareButtonProps {
title?: string;
text?: string;
url?: string;
className?: string;
}
export default function ShareButton({
title = 'Check out this wishlist!',
text = 'I thought you might be interested in this wishlist.',
url,
className = ''
}: ShareButtonProps) {
const [isSupported, setIsSupported] = useState(true);
const handleShare = async () => {
// Use current URL if not provided
const shareUrl = url || window.location.href;
// Check if Web Share API is supported
if (!navigator.share) {
setIsSupported(false);
// Fallback: copy to clipboard
try {
await navigator.clipboard.writeText(shareUrl);
alert('Link copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
alert('Unable to share or copy link');
}
return;
}
try {
await navigator.share({
title,
text,
url: shareUrl,
});
} catch (err: any) {
// User cancelled or share failed
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
};
if (!isSupported && typeof window !== 'undefined' && !navigator.clipboard) {
// Don't show button if neither share nor clipboard is supported
return null;
}
return (
<button
onClick={handleShare}
className={`inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all cursor-pointer ${className}`}
title="Share this page"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
Share
</button>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Load theme from localStorage
const savedTheme = localStorage.getItem('theme') as Theme | null;
const initialTheme = savedTheme || 'light';
setTheme(initialTheme);
document.documentElement.setAttribute('data-theme', initialTheme);
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
app:
image: reggiodigital/wishlist:latest
container_name: wishlist
restart: unless-stopped
ports:
- "3000:3000"
environment:
# User/Group IDs (defaults to 1000:1000, Unraid users set to 99:100)
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
# Admin credentials
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
volumes:
- /folder-for-wishlist-data:/app/data

46
entrypoint.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/sh
set -e
# Default to 1000:1000 (standard Linux user)
PUID=${PUID:-1000}
PGID=${PGID:-1000}
echo "
-------------------------------------
User UID: $PUID
User GID: $PGID
-------------------------------------
"
# Create group if it doesn't exist
if ! getent group ${PGID} > /dev/null 2>&1; then
addgroup -g ${PGID} appgroup
GROUP_NAME="appgroup"
else
GROUP_NAME=$(getent group ${PGID} | cut -d: -f1)
fi
# Create user if it doesn't exist
if ! getent passwd ${PUID} > /dev/null 2>&1; then
adduser -S -u ${PUID} -G ${GROUP_NAME} -h /app appuser
fi
# Ensure data directories exist
echo "Creating data directories if they don't exist..."
mkdir -p /app/data/db /app/data/uploads
# Fix ownership of all data directories
echo "Setting ownership to ${PUID}:${PGID}..."
chown -R ${PUID}:${PGID} /app/data
# Set proper permissions
echo "Setting permissions..."
chmod -R 775 /app/data
echo "Data directory permissions configured successfully"
# Set umask for proper file creation permissions
umask 0002
# Switch to the specified user and execute the command
exec su-exec ${PUID}:${PGID} "$@"

18
eslint.config.mjs Normal file
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;

13
instrumentation.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Next.js Instrumentation Hook
* This runs once when the Next.js server starts up
* Perfect for initializing the database and seeding it with sample data
*/
export async function register() {
// Only run in production/development runtime, not during build
if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NEXT_PHASE !== 'phase-production-build') {
const { initializeDatabase } = await import('./lib/db/index');
await initializeDatabase();
}
}

312
lib/api.ts Normal file
View File

@@ -0,0 +1,312 @@
// API client for Next.js API routes
const API_BASE_URL = '/api';
export interface ApiError {
message: string;
status: number;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'An error occurred' }));
// Extract error message from various possible response formats
const message = error.error || error.message || 'An error occurred';
console.error('API Error:', { status: response.status, message, fullError: error });
throw { message, status: response.status } as ApiError;
}
// Handle empty responses
const text = await response.text();
return text ? JSON.parse(text) : ({} as T);
}
// Auth API
export const authApi = {
async login(username: string, password: string) {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
return handleResponse<{ accessToken: string; refreshToken: string }>(response);
},
async logout() {
const response = await fetch(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
return handleResponse<void>(response);
},
async refresh() {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
return handleResponse<{ accessToken: string }>(response);
},
async me() {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
credentials: 'include',
});
return handleResponse<{ username: string }>(response);
},
};
// Wishlist types
export interface Wishlist {
id: string;
name: string;
slug: string;
description: string | null;
preferences: string | null;
imageUrl: string | null;
isPublic: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface Item {
id: string;
wishlistId: string;
name: string;
description: string | null;
price: number | null;
currency: string;
quantity: number;
imageUrl: string | null;
purchaseUrls: Array<{ label: string; url: string }> | null;
isArchived: boolean;
claimedByName: string | null;
claimedByNote: string | null;
claimedAt: string | null;
isPurchased: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
// Wishlists API
export const wishlistsApi = {
async getAll() {
const response = await fetch(`${API_BASE_URL}/wishlists`, {
credentials: 'include',
});
const data = await handleResponse<{ success: boolean; wishlists: Wishlist[] }>(response);
return data.wishlists;
},
async getAllPublic() {
const response = await fetch(`${API_BASE_URL}/public/wishlists`, {
credentials: 'include',
});
const data = await handleResponse<{ success: boolean; wishlists: Wishlist[] }>(response);
return data.wishlists;
},
async getOne(id: string) {
const response = await fetch(`${API_BASE_URL}/wishlists/${id}`, {
credentials: 'include',
});
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
return result.wishlist;
},
async getBySlug(slug: string) {
const response = await fetch(`${API_BASE_URL}/${slug}`, {
credentials: 'include',
});
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
return result.wishlist;
},
async create(data: Partial<Wishlist>) {
const response = await fetch(`${API_BASE_URL}/wishlists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
return result.wishlist;
},
async update(id: string, data: Partial<Wishlist>) {
const response = await fetch(`${API_BASE_URL}/wishlists/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
return result.wishlist;
},
async delete(id: string) {
const response = await fetch(`${API_BASE_URL}/wishlists/${id}`, {
method: 'DELETE',
credentials: 'include',
});
return handleResponse<void>(response);
},
async reorder(id: string, newSortOrder: number) {
const response = await fetch(`${API_BASE_URL}/wishlists/${id}/reorder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ newSortOrder }),
});
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
return result.wishlist;
},
};
// Items API
export const itemsApi = {
async getAll(wishlistId: string) {
const response = await fetch(`${API_BASE_URL}/wishlists/${wishlistId}/items`, {
credentials: 'include',
});
const result = await handleResponse<{ success: boolean; items: Item[] }>(response);
return result.items;
},
async getOne(id: string) {
const response = await fetch(`${API_BASE_URL}/items/${id}`, {
credentials: 'include',
});
return handleResponse<Item>(response);
},
async create(wishlistId: string, data: Partial<Item>) {
const response = await fetch(`${API_BASE_URL}/wishlists/${wishlistId}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
return handleResponse<Item>(response);
},
async update(id: string, data: Partial<Item>) {
const response = await fetch(`${API_BASE_URL}/items/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
return handleResponse<Item>(response);
},
async delete(id: string) {
const response = await fetch(`${API_BASE_URL}/items/${id}`, {
method: 'DELETE',
credentials: 'include',
});
return handleResponse<void>(response);
},
async reorder(id: string, newSortOrder: number) {
const response = await fetch(`${API_BASE_URL}/items/${id}/reorder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ newSortOrder }),
});
const result = await handleResponse<{ success: boolean; item: Item }>(response);
return result.item;
},
};
// Claiming API (public)
export const claimingApi = {
async claim(itemId: string, name?: string, note?: string) {
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, note }),
});
return handleResponse<{ claimToken: string; message: string }>(response);
},
async unclaim(itemId: string) {
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/unclaim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
return handleResponse<{ success: boolean; message: string }>(response);
},
};
// Scraping API
export interface ScrapedData {
title?: string;
description?: string;
price?: number;
currency?: string;
imageUrl?: string;
}
export const scrapingApi = {
async scrapeUrl(url: string) {
const response = await fetch(`${API_BASE_URL}/scrape`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ url }),
});
return handleResponse<ScrapedData>(response);
},
};
// Settings API
export interface Settings {
siteTitle: string;
homepageSubtext: string;
passwordLockEnabled?: boolean;
passwordLock?: string;
}
export const settingsApi = {
async getSettings() {
const response = await fetch(`${API_BASE_URL}/settings`, {
credentials: 'include',
});
const result = await handleResponse<{ success: boolean; settings: Settings }>(response);
return result.settings;
},
async updateSettings(settings: Partial<Settings>) {
const response = await fetch(`${API_BASE_URL}/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(settings),
});
return handleResponse<{ success: boolean; message: string }>(response);
},
};

83
lib/auth-context.tsx Normal file
View File

@@ -0,0 +1,83 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authApi } from './api';
import { useRouter } from 'next/navigation';
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
username: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Check authentication on mount using httpOnly cookies
useEffect(() => {
const initAuth = async () => {
try {
// Call /api/auth/me - cookies are sent automatically
const user = await authApi.me();
setIsAuthenticated(true);
setUsername(user.username);
} catch (error) {
// Not authenticated or session expired
setIsAuthenticated(false);
setUsername(null);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = async (username: string, password: string) => {
// Server sets httpOnly cookies automatically
await authApi.login(username, password);
setIsAuthenticated(true);
setUsername(username);
};
const logout = async () => {
try {
// Server clears httpOnly cookies
await authApi.logout();
} catch (error) {
// Continue with logout even if API call fails
}
setIsAuthenticated(false);
setUsername(null);
router.push('/');
};
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
username,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

130
lib/auth/utils.ts Normal file
View File

@@ -0,0 +1,130 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// Initialize secrets (auto-generate if not provided)
const secrets = initializeSecrets();
// Token expiry times
const TOKEN_EXPIRY = '72h';
const REFRESH_TOKEN_EXPIRY = '30d';
/**
* Initialize JWT secrets - auto-generate and persist if not provided in environment
*/
function initializeSecrets(): { secret: string; refreshSecret: string } {
const dataDir = path.join(process.cwd(), 'data');
const secretsFile = path.join(dataDir, 'secrets.json');
// If provided in environment, use those
if (process.env.SECRET) {
return {
secret: process.env.SECRET,
refreshSecret: process.env.REFRESH_SECRET || process.env.SECRET, // Use same secret if refresh not provided
};
}
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Try to load existing secrets
if (fs.existsSync(secretsFile)) {
try {
const data = JSON.parse(fs.readFileSync(secretsFile, 'utf-8'));
return data;
} catch {
// Failed to load, will generate new ones
}
}
// Generate new cryptographically secure secrets (512 bits each)
const newSecrets = {
secret: crypto.randomBytes(64).toString('hex'),
refreshSecret: crypto.randomBytes(64).toString('hex'),
};
// Save to file with restricted permissions
try {
fs.writeFileSync(secretsFile, JSON.stringify(newSecrets, null, 2), { mode: 0o600 });
} catch (error) {
console.error('⚠️ Failed to save secrets file:', error);
console.error('⚠️ WARNING: Using in-memory secrets - tokens will be invalid after restart!');
}
return newSecrets;
}
export interface TokenPayload {
username: string;
type: 'access' | 'refresh';
}
/**
* Generate an access token
*/
export function generateAccessToken(username: string): string {
return jwt.sign(
{ username, type: 'access' } as TokenPayload,
secrets.secret,
{ expiresIn: TOKEN_EXPIRY } as jwt.SignOptions
);
}
/**
* Generate a refresh token
*/
export function generateRefreshToken(username: string): string {
return jwt.sign(
{ username, type: 'refresh' } as TokenPayload,
secrets.refreshSecret,
{ expiresIn: REFRESH_TOKEN_EXPIRY } as jwt.SignOptions
);
}
/**
* Verify an access token
*/
export function verifyAccessToken(token: string): TokenPayload | null {
try {
const payload = jwt.verify(token, secrets.secret) as TokenPayload;
if (payload.type !== 'access') {
return null;
}
return payload;
} catch (error) {
return null;
}
}
/**
* Verify a refresh token
*/
export function verifyRefreshToken(token: string): TokenPayload | null {
try {
const payload = jwt.verify(token, secrets.refreshSecret) as TokenPayload;
if (payload.type !== 'refresh') {
return null;
}
return payload;
} catch (error) {
return null;
}
}
/**
* Validate admin credentials against environment variables
*/
export function validateAdminCredentials(username: string, password: string): boolean {
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword) {
console.error('❌ ADMIN_PASSWORD not set in environment variables');
return false;
}
return username === adminUsername && password === adminPassword;
}

146
lib/db/index.ts Normal file
View File

@@ -0,0 +1,146 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
import fs from 'fs';
// Lazy initialization to avoid database access during build
let _db: ReturnType<typeof drizzle> | null = null;
let _sqlite: Database.Database | null = null;
function getDb() {
if (!_db) {
// Ensure data directories exist
const dataDir = path.join(process.cwd(), 'data');
const dbDir = path.join(dataDir, 'db');
const uploadsDir = path.join(dataDir, 'uploads');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Database file path
const dbPath = path.join(dbDir, 'wishlist.db');
// Create SQLite database connection
_sqlite = new Database(dbPath);
_sqlite.pragma('journal_mode = WAL'); // Better concurrency
// Create Drizzle instance
_db = drizzle(_sqlite, { schema });
}
return _db;
}
// Export db as a getter
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(target, prop) {
return getDb()[prop as keyof ReturnType<typeof drizzle>];
}
});
// Initialize database (create tables and seed if needed)
export async function initializeDatabase() {
try {
// Ensure database is initialized
const sqlite = _sqlite || getDb() && _sqlite;
if (!sqlite) throw new Error('Failed to initialize database');
// Create wishlists table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS wishlists (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
preferences TEXT,
image_url TEXT,
notes TEXT,
is_public INTEGER DEFAULT 0 NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
)
`);
// Create wishlist_items table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS wishlist_items (
id TEXT PRIMARY KEY NOT NULL,
wishlist_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
price REAL,
currency TEXT DEFAULT 'USD' NOT NULL,
quantity INTEGER DEFAULT 1 NOT NULL,
images TEXT,
purchase_urls TEXT,
notes TEXT,
is_archived INTEGER DEFAULT 0 NOT NULL,
claimed_by_name TEXT,
claimed_by_note TEXT,
claimed_by_token TEXT UNIQUE,
claimed_at INTEGER,
is_purchased INTEGER DEFAULT 0 NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (wishlist_id) REFERENCES wishlists(id) ON DELETE CASCADE
)
`);
// Create settings table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY NOT NULL,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
)
`);
// Run migrations for existing databases
try {
const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>;
// Add image_url column if it doesn't exist
const hasImageUrl = columns.some((col) => col.name === 'image_url');
if (!hasImageUrl) {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN image_url TEXT');
console.log('✅ Added image_url column to wishlists table');
}
// Add sort_order column if it doesn't exist
const hasSortOrder = columns.some((col) => col.name === 'sort_order');
if (!hasSortOrder) {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN sort_order INTEGER DEFAULT 0 NOT NULL');
console.log('✅ Added sort_order column to wishlists table');
}
// Add preferences column if it doesn't exist
const hasPreferences = columns.some((col) => col.name === 'preferences');
if (!hasPreferences) {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT');
console.log('✅ Added preferences column to wishlists table');
}
} catch (migrationError) {
console.log('Migration already applied or not needed');
}
// Auto-seed database if empty
const { seedDatabase } = await import('./seed');
await seedDatabase();
return true;
} catch (error) {
console.error('❌ Database initialization failed:', error);
throw error;
}
}
// Export schema for use in other files
export * from './schema';

68
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,68 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { createId } from '@paralleldrive/cuid2';
import { sql } from 'drizzle-orm';
/**
* Database schema for family wishlist app
*
* Simplified for self-hosted family use:
* - No Settings table (use env vars instead)
* - Image URLs only (no upload handling in MVP)
* - Simplified purchase URLs (no usage tracking)
*/
// Wishlists table
export const wishlists = sqliteTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
description: text('description'),
preferences: text('preferences'), // General interests/likes section
imageUrl: text('image_url'),
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Wishlist items table
export const wishlistItems = sqliteTable('wishlist_items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
price: real('price'),
currency: text('currency').notNull().default('USD'),
quantity: integer('quantity').notNull().default(1),
imageUrl: text('images'), // Stored as 'images' in DB but exposed as imageUrl
purchaseUrls: text('purchase_urls', { mode: 'json' }).$type<Array<{
label: string;
url: string;
}>>(),
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
// Claim information
claimedByName: text('claimed_by_name'),
claimedByNote: text('claimed_by_note'),
claimedByToken: text('claimed_by_token').unique(),
claimedAt: integer('claimed_at', { mode: 'timestamp' }),
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Settings table
export const settings = sqliteTable('settings', {
id: text('id').primaryKey().$defaultFn(() => createId()),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Type exports
export type Wishlist = typeof wishlists.$inferSelect;
export type WishlistItem = typeof wishlistItems.$inferSelect;
export type Setting = typeof settings.$inferSelect;

303
lib/db/seed.ts Normal file
View File

@@ -0,0 +1,303 @@
import { db } from './index';
import { wishlists, wishlistItems, settings } from './schema';
import { eq } from 'drizzle-orm';
/**
* Seed the database with sample data
* This creates example wishlists and items to help users get started
*/
export async function seedDatabase() {
try {
// Check if data already exists
const existingWishlists = await db.select().from(wishlists).limit(1);
if (existingWishlists.length > 0) {
return;
}
// Seed settings
await db.insert(settings).values([
{
key: 'siteTitle',
value: 'Wishlist',
},
{
key: 'homepageSubtext',
value: 'Hello! Thank you so much for thinking of us! When you purchase something from our list, just click "Claim" to mark it. We promise not to peek at what\'s been claimed. This works on the honor system, so please only claim items you\'ve actually bought. We appreciate you!',
},
]);
// Create sample wishlists
const [dadWishlist] = await db.insert(wishlists).values({
name: "Dad's Wishlist",
slug: 'dads-wishlist',
description: 'Birthday and holiday gift ideas',
imageUrl: '/images/wishlists/dad.png',
isPublic: true,
}).returning();
const [momWishlist] = await db.insert(wishlists).values({
name: "Mom's Wishlist",
slug: 'moms-wishlist',
description: 'Things I would love!',
imageUrl: '/images/wishlists/mom.png',
isPublic: true,
}).returning();
const [childWishlist] = await db.insert(wishlists).values({
name: "Child's Wishlist",
slug: 'childs-wishlist',
description: 'Toys, books, and fun things!',
imageUrl: '/images/wishlists/child.png',
isPublic: true,
}).returning();
// Dad's wishlist items
await db.insert(wishlistItems).values([
{
wishlistId: dadWishlist.id,
name: 'The Book of Unusual Knowledge: Big Book of Fascinating Facts & Information',
description: 'I love learning random trivia and this looks like the perfect coffee table book to flip through when I have a few minutes.',
price: 10.00,
currency: 'USD',
quantity: 1,
sortOrder: 0,
imageUrl: '/images/items/dad1.jpg',
purchaseUrls: [
{ label: 'Amazon - $10.00', url: 'https://www.amazon.com/Unusual-Knowledge-Editors-Publications-International/dp/1450845800/ref=sr_1_6' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Anker PowerCore 10000 Portable Charger',
description: 'My phone always dies when I need it most. This would be perfect for keeping in my bag for emergencies.',
price: 26.00,
currency: 'USD',
quantity: 1,
sortOrder: 1,
imageUrl: '/images/items/dad2.jpg',
purchaseUrls: [
{ label: 'Amazon - $26.00', url: 'https://www.amazon.com/Anker-PowerCore-Ultra-Compact-High-Speed-Technology/dp/B0194WDVHI' },
{ label: 'Micro Center - $29.99', url: 'https://www.microcenter.com/product/686695/anker-10k-225w-power-bank' },
{ label: 'Staples - $34.99', url: 'https://www.staples.com/anker-powercore-power-bank-10000mah-22-5w-portable-charger-with-usb-c-lanyard-cable-black-a1388h11-1/product_24617552' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Yeti Rambler 20 oz Tumbler',
description: 'I want something that keeps my coffee hot during my entire commute. This looks like it would be perfect.',
price: 30.00,
currency: 'USD',
quantity: 1,
sortOrder: 2,
imageUrl: '/images/items/dad3.webp',
purchaseUrls: [
{ label: 'Amazon - $30.00', url: 'https://www.amazon.com/YETI-Rambler-Tumbler-Vacuum-Insulated/dp/B073WKWYJJ' },
{ label: 'YETI - $30.00', url: 'https://www.yeti.com/drinkware/tumblers/rambler-20-oz-tumbler.html' },
{ label: 'REI - $30.00', url: 'https://www.rei.com/product/113804/yeti-rambler-20-fl-oz-tumbler' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Tile Mate Bluetooth Tracker 4-Pack',
description: 'I lose my keys at least twice a week. These would save me so much time and frustration.',
price: 70.00,
currency: 'USD',
quantity: 1,
sortOrder: 3,
imageUrl: '/images/items/dad4.webp',
purchaseUrls: [
{ label: 'Amazon - $70.00', url: 'https://www.amazon.com/Tile-Mate-4-Pack-Bluetooth-Finder/dp/B09B2WLRWH' },
{ label: 'Best Buy - $74.99', url: 'https://www.bestbuy.com/site/tile-mate-bluetooth-tracker-4-pack/6451674.p' },
{ label: 'Target - $69.99', url: 'https://www.target.com/p/tile-mate-bluetooth-tracker-4pk/-/A-82215989' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Leatherman Wave Plus Multi-Tool',
description: 'I always need a tool when I don\'t have one. This has everything in one compact package.',
price: 120.00,
currency: 'USD',
quantity: 1,
sortOrder: 4,
imageUrl: '/images/items/dad5.jpg',
purchaseUrls: [
{ label: 'Amazon - $120.00', url: 'https://www.amazon.com/LEATHERMAN-Wave-Multitool-Black-Molle/dp/B07DD69QN3' },
{ label: 'REI - $124.95', url: 'https://www.rei.com/product/766953/leatherman-wave-plus-multitool' },
{ label: 'Home Depot - $119.99', url: 'https://www.homedepot.com/p/LEATHERMAN-Wave-Plus-Multi-Tool-832524/305408085' },
],
},
]);
// Mom's wishlist items
await db.insert(wishlistItems).values([
{
wishlistId: momWishlist.id,
name: 'Kindle Paperwhite (16 GB)',
description: 'I love reading before bed and this would be perfect for not disturbing anyone with a lamp.',
price: 140.00,
currency: 'USD',
quantity: 1,
sortOrder: 0,
imageUrl: '/images/items/mom1.jpg',
purchaseUrls: [
{ label: 'Amazon - $139.99', url: 'https://www.amazon.com/Kindle-Paperwhite-16-GB/dp/B08N3J8GTX' },
{ label: 'Best Buy - $139.99', url: 'https://www.bestbuy.com/site/amazon-kindle-paperwhite-16gb/6522383.p' },
{ label: 'Target - $139.99', url: 'https://www.target.com/p/kindle-paperwhite/-/A-84491392' },
],
},
{
wishlistId: momWishlist.id,
name: 'Lululemon Align High-Rise Pant 25"',
description: 'I need comfortable leggings for yoga class and these are supposed to be amazing.',
price: 98.00,
currency: 'USD',
quantity: 1,
sortOrder: 1,
imageUrl: '/images/items/mom2.webp',
purchaseUrls: [
{ label: 'Lululemon - $98.00', url: 'https://shop.lululemon.com/p/women-pants/Align-Pant-2' },
{ label: 'Amazon - $98.00', url: 'https://www.amazon.com/stores/page/lululemon' },
],
},
{
wishlistId: momWishlist.id,
name: 'Hydro Flask 32 oz Wide Mouth Water Bottle',
description: 'I want to drink more water throughout the day and this keeps drinks cold for 24 hours.',
price: 45.00,
currency: 'USD',
quantity: 1,
sortOrder: 2,
imageUrl: '/images/items/mom3.jpg',
purchaseUrls: [
{ label: 'Amazon - $44.95', url: 'https://www.amazon.com/Hydro-Flask-Water-Bottle-Stainless/dp/B01ACVS6RE' },
{ label: 'REI - $44.95', url: 'https://www.rei.com/product/889468/hydro-flask-wide-mouth-water-bottle-32-fl-oz' },
{ label: 'Dick\'s Sporting Goods - $44.99', url: 'https://www.dickssportinggoods.com/p/hydro-flask-32-oz-wide-mouth-bottle/16hflu32zwd' },
],
},
{
wishlistId: momWishlist.id,
name: 'Revlon One-Step Volumizer Hair Dryer',
description: 'My hair takes forever to style and this looks like it would save me so much time in the morning.',
price: 40.00,
currency: 'USD',
quantity: 1,
sortOrder: 3,
imageUrl: '/images/items/mom4.jpg',
purchaseUrls: [
{ label: 'Amazon - $39.99', url: 'https://www.amazon.com/Revlon-One-Step-Dryer-Volumizer/dp/B01LSUQSB0' },
{ label: 'Target - $39.99', url: 'https://www.target.com/p/revlon-one-step-volumizer/-/A-53003976' },
{ label: 'Walmart - $39.96', url: 'https://www.walmart.com/ip/Revlon-One-Step-Hair-Dryer-Volumizer/55689116' },
],
},
{
wishlistId: momWishlist.id,
name: 'Apple AirPods Pro (2nd Generation)',
description: 'I need better earbuds for my workouts and calls. These have noise cancellation which would be perfect for the gym.',
price: 249.00,
currency: 'USD',
quantity: 1,
sortOrder: 4,
imageUrl: '/images/items/mom5.jpg',
purchaseUrls: [
{ label: 'Amazon - $249.00', url: 'https://www.amazon.com/Apple-Generation-Cancelling-Transparency-Personalized/dp/B0CHWRXH8B' },
{ label: 'Best Buy - $249.99', url: 'https://www.bestbuy.com/site/apple-airpods-pro-2nd-generation/6447382.p' },
{ label: 'Target - $249.99', url: 'https://www.target.com/p/apple-airpods-pro-2nd-generation/-/A-85978622' },
],
},
]);
// Child's wishlist items
await db.insert(wishlistItems).values([
{
wishlistId: childWishlist.id,
name: 'LEGO Classic Medium Creative Brick Box',
description: 'I love building things and making my own creations! This has so many pieces to build anything I can imagine.',
price: 30.00,
currency: 'USD',
quantity: 1,
sortOrder: 0,
imageUrl: '/images/items/child1.jpg',
purchaseUrls: [
{ label: 'Amazon - $29.99', url: 'https://www.amazon.com/LEGO-Classic-Medium-Creative-Construction/dp/B00NHQFA3Y' },
{ label: 'Target - $29.99', url: 'https://www.target.com/p/lego-classic-medium-creative-brick-box/-/A-14182781' },
{ label: 'Walmart - $29.97', url: 'https://www.walmart.com/ip/LEGO-Classic-Medium-Creative-Brick-Box-10696/34611691' },
],
},
{
wishlistId: childWishlist.id,
name: 'Crayola Ultimate Crayon Collection',
description: 'I need more colors for my drawings! This has 152 crayons with all the colors I could ever want.',
price: 20.00,
currency: 'USD',
quantity: 1,
sortOrder: 1,
imageUrl: '/images/items/child2.jpg',
purchaseUrls: [
{ label: 'Amazon - $19.99', url: 'https://www.amazon.com/Crayola-Ultimate-Crayon-Collection-Colors/dp/B00LH32JDE' },
{ label: 'Target - $19.99', url: 'https://www.target.com/p/crayola-ultimate-crayon-collection/-/A-14676404' },
{ label: 'Walmart - $19.94', url: 'https://www.walmart.com/ip/Crayola-Ultimate-Crayon-Collection-152-Colors/26228172' },
],
},
{
wishlistId: childWishlist.id,
name: 'National Geographic Kids World Atlas',
description: 'I want to learn about all the different countries and places in the world. This book has cool maps and pictures!',
price: 25.00,
currency: 'USD',
quantity: 1,
sortOrder: 2,
imageUrl: '/images/items/child3.jpg',
purchaseUrls: [
{ label: 'Amazon - $24.99', url: 'https://www.amazon.com/National-Geographic-Kids-World-Atlas/dp/1426375905' },
{ label: 'Barnes & Noble - $24.99', url: 'https://www.barnesandnoble.com/w/national-geographic-kids-world-atlas/1141356542' },
{ label: 'Target - $24.99', url: 'https://www.target.com/p/national-geographic-kids-world-atlas/-/A-87621944' },
],
},
{
wishlistId: childWishlist.id,
name: 'Razor A Kick Scooter',
description: 'All my friends have scooters and I really want one too so I can ride with them to the park!',
price: 40.00,
currency: 'USD',
quantity: 1,
sortOrder: 3,
imageUrl: '/images/items/child4.jpg',
purchaseUrls: [
{ label: 'Amazon - $39.99', url: 'https://www.amazon.com/Razor-Kick-Scooter-FFP/dp/B00005NBON' },
{ label: 'Target - $39.99', url: 'https://www.target.com/p/razor-a-kick-scooter/-/A-10853734' },
{ label: 'Walmart - $39.88', url: 'https://www.walmart.com/ip/Razor-A-Kick-Scooter/10314287' },
],
},
{
wishlistId: childWishlist.id,
name: 'Melissa & Doug Wooden Building Blocks Set',
description: 'I like making towers and houses with blocks. These wooden ones look really cool and sturdy!',
price: 35.00,
currency: 'USD',
quantity: 1,
sortOrder: 4,
imageUrl: '/images/items/child5.jpg',
purchaseUrls: [
{ label: 'Amazon - $34.99', url: 'https://www.amazon.com/Melissa-Doug-Standard-Wooden-Building/dp/B00005RF5G' },
{ label: 'Target - $34.99', url: 'https://www.target.com/p/melissa-doug-wooden-building-blocks-set/-/A-10917067' },
{ label: 'Walmart - $34.97', url: 'https://www.walmart.com/ip/Melissa-Doug-Wooden-Building-Blocks-Set/5024533' },
],
},
]);
return true;
} catch (error) {
console.error('❌ Database seeding failed:', error);
throw error;
}
}
// Run seed if called directly
if (require.main === module) {
seedDatabase()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
}

348
lib/scraping/service.ts Normal file
View File

@@ -0,0 +1,348 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
export interface ScrapedData {
title: string | null;
description: string | null;
price: number | null;
currency: string | null;
imageUrl: string | null;
url: string;
}
/**
* Fetch and parse HTML from a URL
*/
async function fetchHtml(url: string): Promise<string> {
try {
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
},
timeout: 10000, // 10 second timeout
});
return response.data;
} catch (error) {
throw new Error(`Failed to fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Extract price from text (e.g., "$99.99", "99,99 €", "£50")
*/
function extractPrice(text: string): { price: number | null; currency: string | null } {
// Remove whitespace and normalize
const normalized = text.trim().replace(/\s+/g, '');
// Match currency symbols and numbers
// Patterns: $99.99, 99.99 USD, €99,99, £50.00, etc.
const patterns = [
/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/, // $99.99, $1,299.99
/(\d+(?:,\d{3})*(?:\.\d{2})?)\s*USD/i, // 99.99 USD
/€(\d+(?:\.\d{3})*(?:,\d{2})?)/, // €99,99
/£(\d+(?:,\d{3})*(?:\.\d{2})?)/, // £99.99
/(\d+(?:,\d{3})*(?:\.\d{2})?)/, // Fallback: just numbers
];
for (const pattern of patterns) {
const match = normalized.match(pattern);
if (match) {
// Extract number and remove commas, convert to float
const priceStr = match[1].replace(/,/g, '');
const price = parseFloat(priceStr);
if (!isNaN(price)) {
// Determine currency
let currency = 'USD';
if (normalized.includes('$')) currency = 'USD';
else if (normalized.includes('€')) currency = 'EUR';
else if (normalized.includes('£')) currency = 'GBP';
else if (normalized.match(/USD/i)) currency = 'USD';
else if (normalized.match(/EUR/i)) currency = 'EUR';
else if (normalized.match(/GBP/i)) currency = 'GBP';
return { price, currency };
}
}
}
return { price: null, currency: null };
}
/**
* Generic scraper using Open Graph and meta tags
*/
function scrapeGeneric(html: string, url: string): ScrapedData {
const $ = cheerio.load(html);
// Try Open Graph tags first
const ogTitle = $('meta[property="og:title"]').attr('content');
const ogDescription = $('meta[property="og:description"]').attr('content');
const ogImage = $('meta[property="og:image"]').attr('content');
const ogPriceAmount = $('meta[property="og:price:amount"]').attr('content');
const ogPriceCurrency = $('meta[property="og:price:currency"]').attr('content');
// Fallback to other meta tags
const metaDescription = $('meta[name="description"]').attr('content');
const title = ogTitle || $('title').text().trim() || null;
const description = ogDescription || metaDescription || null;
const imageUrl = ogImage || $('link[rel="image_src"]').attr('href') || null;
// Try to extract price from OG tags or page content
let price: number | null = null;
let currency: string | null = null;
if (ogPriceAmount && ogPriceCurrency) {
price = parseFloat(ogPriceAmount);
currency = ogPriceCurrency;
} else {
// Try to find price in common selectors
const priceSelectors = [
'.price',
'[data-price]',
'.product-price',
'[itemprop="price"]',
'.a-price .a-offscreen', // Amazon
];
for (const selector of priceSelectors) {
const priceText = $(selector).first().text();
if (priceText) {
const extracted = extractPrice(priceText);
if (extracted.price !== null) {
price = extracted.price;
currency = extracted.currency;
break;
}
}
}
}
return {
title,
description,
price,
currency,
imageUrl,
url,
};
}
/**
* Amazon-specific scraper
*/
function scrapeAmazon(html: string, url: string): ScrapedData {
const $ = cheerio.load(html);
// Amazon-specific selectors
const title = $('#productTitle').text().trim() ||
$('span[id="productTitle"]').text().trim() ||
null;
const description = $('#feature-bullets ul li').first().text().trim() ||
$('meta[name="description"]').attr('content') ||
null;
// Amazon price selectors (they change frequently)
let price: number | null = null;
let currency: string | null = null;
const priceWhole = $('.a-price-whole').first().text().trim();
const priceFraction = $('.a-price-fraction').first().text().trim();
if (priceWhole) {
const priceStr = priceWhole.replace(',', '') + (priceFraction || '00');
price = parseFloat(priceStr);
currency = 'USD'; // Default, could be enhanced to detect from page
}
// Fallback to other price selectors
if (price === null) {
const priceSelectors = [
'.a-price .a-offscreen',
'#priceblock_ourprice',
'#priceblock_dealprice',
'.a-price-whole',
];
for (const selector of priceSelectors) {
const priceText = $(selector).first().text();
if (priceText) {
const extracted = extractPrice(priceText);
if (extracted.price !== null) {
price = extracted.price;
currency = extracted.currency;
break;
}
}
}
}
// Amazon images
const imageUrl = $('#landingImage').attr('src') ||
$('#imgBlkFront').attr('src') ||
$('img[data-old-hires]').attr('data-old-hires') ||
$('meta[property="og:image"]').attr('content') ||
null;
return {
title,
description,
price,
currency,
imageUrl,
url,
};
}
/**
* Target-specific scraper
*/
function scrapeTarget(html: string, url: string): ScrapedData {
const $ = cheerio.load(html);
const title = $('h1[data-test="product-title"]').text().trim() ||
$('meta[property="og:title"]').attr('content') ||
null;
const description = $('div[data-test="item-details-description"]').text().trim() ||
$('meta[property="og:description"]').attr('content') ||
null;
let price: number | null = null;
let currency = 'USD';
const priceText = $('span[data-test="product-price"]').first().text() ||
$('div[data-test="product-price"]').first().text();
if (priceText) {
const extracted = extractPrice(priceText);
price = extracted.price;
currency = extracted.currency || 'USD';
}
const imageUrl = $('img[data-test="product-image"]').attr('src') ||
$('meta[property="og:image"]').attr('content') ||
null;
return {
title,
description,
price,
currency,
imageUrl,
url,
};
}
/**
* Walmart-specific scraper
*/
function scrapeWalmart(html: string, url: string): ScrapedData {
const $ = cheerio.load(html);
const title = $('h1[itemprop="name"]').text().trim() ||
$('meta[property="og:title"]').attr('content') ||
null;
const description = $('div[itemprop="description"]').text().trim() ||
$('meta[property="og:description"]').attr('content') ||
null;
let price: number | null = null;
let currency = 'USD';
const priceText = $('span[itemprop="price"]').first().attr('content') ||
$('span[itemprop="price"]').first().text() ||
$('.price-characteristic').first().text();
if (priceText) {
const extracted = extractPrice(priceText);
price = extracted.price;
currency = extracted.currency || 'USD';
}
const imageUrl = $('img[itemprop="image"]').attr('src') ||
$('meta[property="og:image"]').attr('content') ||
null;
return {
title,
description,
price,
currency,
imageUrl,
url,
};
}
/**
* Best Buy-specific scraper
*/
function scrapeBestBuy(html: string, url: string): ScrapedData {
const $ = cheerio.load(html);
const title = $('h1.heading-5').first().text().trim() ||
$('meta[property="og:title"]').attr('content') ||
null;
const description = $('div.shop-product-description').first().text().trim() ||
$('meta[property="og:description"]').attr('content') ||
null;
let price: number | null = null;
let currency = 'USD';
const priceText = $('div[data-testid="customer-price"] span').first().text() ||
$('.priceView-hero-price span').first().text();
if (priceText) {
const extracted = extractPrice(priceText);
price = extracted.price;
currency = extracted.currency || 'USD';
}
const imageUrl = $('img.primary-image').first().attr('src') ||
$('meta[property="og:image"]').attr('content') ||
null;
return {
title,
description,
price,
currency,
imageUrl,
url,
};
}
/**
* Main scrape function - detects site and uses appropriate scraper
*/
export async function scrapeUrl(url: string): Promise<ScrapedData> {
try {
// Normalize URL
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(normalizedUrl);
const hostname = urlObj.hostname.toLowerCase();
// Fetch HTML
const html = await fetchHtml(normalizedUrl);
// Use site-specific scraper if available
if (hostname.includes('amazon.')) {
return scrapeAmazon(html, normalizedUrl);
} else if (hostname.includes('target.com')) {
return scrapeTarget(html, normalizedUrl);
} else if (hostname.includes('walmart.com')) {
return scrapeWalmart(html, normalizedUrl);
} else if (hostname.includes('bestbuy.com')) {
return scrapeBestBuy(html, normalizedUrl);
}
// Fallback to generic scraper
return scrapeGeneric(html, normalizedUrl);
} catch (error) {
throw new Error(`Scraping failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

35
middleware.ts Normal file
View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip middleware for admin routes, API routes, static files, and the lock page itself
if (
pathname.startsWith('/admin') ||
pathname.startsWith('/api') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/icon.svg') ||
pathname === '/lock'
) {
return NextResponse.next();
}
// The actual password lock check will be done on the client side
// This middleware is kept simple and only used for future extensions
return NextResponse.next();
}
// Configure which routes use this middleware
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

13
next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['better-sqlite3', 'sharp'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
};
export default nextConfig;

9125
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "wishlist",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx lib/db/seed.ts"
},
"dependencies": {
"@lexical/html": "^0.38.2",
"@lexical/link": "^0.38.2",
"@lexical/list": "^0.38.2",
"@lexical/react": "^0.38.2",
"@lexical/rich-text": "^0.38.2",
"@paralleldrive/cuid2": "^3.0.4",
"axios": "^1.13.2",
"better-sqlite3": "^12.4.1",
"cheerio": "^1.1.2",
"drizzle-orm": "^0.44.7",
"jsonwebtoken": "^9.0.2",
"lexical": "^0.38.2",
"next": "16.0.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

34
tsconfig.json Normal file
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"]
}

BIN
video.mp4 Normal file

Binary file not shown.