Initial commit
This commit is contained in:
364
app/[slug]/page.tsx
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user