Initial commit
62
.dockerignore
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,154 @@
|
||||
# Wishlist
|
||||
|
||||
[](https://hub.docker.com/r/reggiodigital/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
|
||||
|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
### Homepage
|
||||
|
||||

|
||||
|
||||
### Wishlist View
|
||||
|
||||

|
||||
|
||||
### Admin Dashboard
|
||||
|
||||

|
||||
|
||||
### Admin Dashboard - Item Details
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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: "{justClaimedNote}"
|
||||
</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
@@ -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
@@ -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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
app/api/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
13
app/api/auth/logout/route.ts
Normal 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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/api/auth/refresh/route.ts
Normal 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
@@ -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(),
|
||||
});
|
||||
}
|
||||
139
app/api/items/[id]/reorder/route.ts
Normal 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
@@ -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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
app/api/public/items/[id]/claim/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
84
app/api/public/items/[id]/unclaim/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
app/api/public/wishlists/route.ts
Normal 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
@@ -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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
app/api/wishlists/[id]/items/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
137
app/api/wishlists/[id]/reorder/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
197
app/api/wishlists/[id]/route.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
79
app/uploads/[...path]/route.ts
Normal 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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
309
components/RichTextEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
components/admin/CreateWishlistModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
components/admin/ItemCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
components/admin/ItemForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
components/admin/PurchaseUrlFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
components/admin/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
components/admin/StatsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
482
components/admin/WishlistCard.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
56
components/password-lock-guard.tsx
Normal 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}</>;
|
||||
}
|
||||
30
components/protected-route.tsx
Normal 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}</>;
|
||||
}
|
||||
79
components/share-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
components/theme-provider.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
50
package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/images/items/child1.jpg
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
public/images/items/child2.jpg
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
public/images/items/child3.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/images/items/child4.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/images/items/child5.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/items/dad1.jpg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
public/images/items/dad2.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/images/items/dad3.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/images/items/dad4.webp
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
public/images/items/dad5.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/images/items/mom1.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/items/mom2.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/images/items/mom3.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/images/items/mom4.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/items/mom5.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/images/wishlists/child.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/wishlists/dad.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/wishlists/mom.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
screenshot1.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot2.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
screenshot3.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshot4.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||