Initial commit

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

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

@@ -0,0 +1,364 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api';
import Header from '@/components/header';
import Footer from '@/components/footer';
import PasswordLockGuard from '@/components/password-lock-guard';
export default function PublicWishlistPage() {
const params = useParams();
const [wishlist, setWishlist] = useState<Wishlist | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [showClaimed, setShowClaimed] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
// Claim form state
const [claimingItemId, setClaimingItemId] = useState<string | null>(null);
const [claimNote, setClaimNote] = useState('');
const [isClaiming, setIsClaiming] = useState(false);
const [claimError, setClaimError] = useState('');
const [justClaimedItemId, setJustClaimedItemId] = useState<string | null>(null);
const [justClaimedNote, setJustClaimedNote] = useState('');
// Unclaim state
const [isUnclaiming, setIsUnclaiming] = useState(false);
const [unclaimError, setUnclaimError] = useState('');
useEffect(() => {
fetchWishlist();
}, [params.slug]);
const fetchWishlist = async () => {
if (!params.slug) return;
try {
const wishlistData = await wishlistsApi.getBySlug(params.slug as string);
setWishlist(wishlistData);
const itemsData = await itemsApi.getAll(wishlistData.id);
setItems(itemsData.sort((a, b) => a.sortOrder - b.sortOrder));
} catch (err: any) {
setError(err.message || 'Wishlist not found');
} finally {
setIsLoading(false);
}
};
const handleClaimItem = (itemId: string) => {
setClaimingItemId(itemId);
setClaimError('');
setClaimNote('');
setJustClaimedItemId(null);
};
const handleSubmitClaim = async (e: React.FormEvent, itemId: string) => {
e.preventDefault();
setIsClaiming(true);
setClaimError('');
try {
await claimingApi.claim(itemId, undefined, claimNote);
setJustClaimedItemId(itemId);
setJustClaimedNote(claimNote);
setClaimingItemId(null);
setClaimNote('');
fetchWishlist();
} catch (err: any) {
setClaimError(err.message || 'Failed to claim item');
} finally {
setIsClaiming(false);
}
};
const handleUnclaim = async (itemId: string) => {
if (!confirm('Are you sure you want to unclaim this item?')) {
return;
}
setIsUnclaiming(true);
setUnclaimError('');
try {
await claimingApi.unclaim(itemId);
fetchWishlist();
} catch (err: any) {
setUnclaimError(err.message || 'Failed to unclaim item');
} finally {
setIsUnclaiming(false);
}
};
const filteredItems = showClaimed
? items
: items.filter((item) => !item.claimedAt || item.id === justClaimedItemId);
const formatPrice = (price: number | null, currency: string) => {
if (!price) return null;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
}).format(price);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
if (error || !wishlist) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Wishlist Not Found</h1>
<p className="text-gray-600 dark:text-gray-400">{error || 'This wishlist does not exist or is not public.'}</p>
</div>
</div>
);
}
return (
<PasswordLockGuard>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header
title={wishlist.name}
subtitle={wishlist.description || undefined}
imageUrl={wishlist.imageUrl || undefined}
maxWidth="max-w-5xl"
/>
{/* Main Content */}
<div className="max-w-5xl mx-auto py-12 sm:px-6 lg:px-8">
<div className="px-4 sm:px-0">
<a
href="/"
className="inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 mb-6 transition-colors cursor-pointer"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Back to Home
</a>
{/* Preferences Section */}
{wishlist.preferences && (
<div className="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-3">
General Interests & Preferences
</h2>
<div
className="prose prose-indigo dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 [&_a]:text-indigo-600 [&_a]:dark:text-indigo-400 [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: wishlist.preferences }}
onClick={(e) => {
// Make all links open in new tab
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
e.preventDefault();
window.open((target as HTMLAnchorElement).href, '_blank', 'noopener,noreferrer');
}
}}
/>
</div>
)}
{/* Controls */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={showClaimed}
onChange={(e) => setShowClaimed(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">Show claimed items</span>
</label>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{filteredItems.length} of {items.length} items
</div>
</div>
{/* Items List */}
{filteredItems.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
<p className="text-gray-500 dark:text-gray-400">
{showClaimed ? 'No items in this wishlist yet' : 'All items have been claimed!'}
</p>
</div>
) : (
<div className="space-y-6">
{filteredItems.map((item) => (
<div
key={item.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 hover:scale-105 overflow-hidden"
>
<div className="flex flex-col md:flex-row">
{/* Left: Image */}
{item.imageUrl && (
<div className="md:w-48 md:flex-shrink-0">
<img
src={item.imageUrl}
alt={item.name}
className="w-full h-48 md:h-full object-cover"
/>
</div>
)}
{/* Middle: Item Details */}
<div className="flex-1 p-6">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{item.name}
</h3>
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
{item.description}
</p>
)}
</div>
{/* Right: Action Area */}
<div className="md:w-80 md:flex-shrink-0 p-6 bg-gray-50 dark:bg-gray-900/50 border-t md:border-t-0 md:border-l border-gray-200 dark:border-gray-700 flex flex-col">
<div className="mb-4">
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
<div className="space-y-2">
{item.purchaseUrls.map((url, idx) => (
<a
key={idx}
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between text-base px-4 py-3 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
>
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
{url.label}
</span>
<span className="text-gray-900 dark:text-white font-bold text-lg">
{item.price && formatPrice(item.price, item.currency)}
</span>
</a>
))}
</div>
)}
</div>
{/* Claimed Badge, Success Message, or Claim Button/Form */}
<div className="mt-auto">
{justClaimedItemId === item.id ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center justify-center mb-2">
<div className="w-12 h-12 bg-green-500 dark:bg-green-600 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<p className="text-center text-lg font-semibold text-gray-900 dark:text-white mb-1">
Item Claimed!
</p>
<p className="text-center text-sm text-gray-600 dark:text-gray-400 mb-2">
The status is now locked.
</p>
{justClaimedNote && (
<p className="text-center text-xs text-gray-600 dark:text-gray-400 italic">
Your Note: &quot;{justClaimedNote}&quot;
</p>
)}
</div>
) : item.claimedAt ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded p-3">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Claimed by {item.claimedByName}
</p>
{item.claimedByNote && (
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
Note: {item.claimedByNote}
</p>
)}
{item.isPurchased && (
<p className="text-xs text-green-700 dark:text-green-300 mt-1 font-medium">
Purchased
</p>
)}
{showClaimed && (
<button
onClick={() => handleUnclaim(item.id)}
disabled={isUnclaiming}
className="mt-3 w-full px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 font-medium disabled:opacity-50 transition-colors cursor-pointer text-sm"
>
{isUnclaiming ? 'Unclaiming...' : 'Unclaim Item'}
</button>
)}
</div>
) : claimingItemId === item.id ? (
<div className="space-y-3">
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
{claimError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
{claimError}
</div>
)}
<div>
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Add a Note (Optional):
</label>
<textarea
id={`claim-note-${item.id}`}
rows={3}
placeholder="Let them know your plans! e.g., 'Buying this next week' or 'Found a great deal online' or 'Need to check the size first'"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white resize-none"
value={claimNote}
onChange={(e) => setClaimNote(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isClaiming}
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
>
{isClaiming ? 'Claiming...' : 'Confirm Claim'}
</button>
</form>
</div>
) : (
<button
onClick={() => handleClaimItem(item.id)}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
>
Claim This Item
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<Footer />
</div>
</PasswordLockGuard>
);
}

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

62
app/globals.css Normal file
View File

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

3
app/icon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 140 B

53
app/layout.tsx Normal file
View File

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

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

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

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

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

107
app/page.tsx Normal file
View File

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

View File

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

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

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