Initial commit: PassMaster PWA MVP

This commit is contained in:
2025-08-26 11:49:01 +02:00
commit 0623e2e29f
56 changed files with 14200 additions and 0 deletions

130
src/components/FAQ.tsx Normal file
View File

@@ -0,0 +1,130 @@
"use client"
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ChevronUp } from 'lucide-react'
interface FAQItem {
question: string
answer: string
}
const faqData: FAQItem[] = [
{
question: "How does offline password generation work?",
answer: "PassMaster generates passwords entirely in your browser using cryptographically secure random number generation. No data is sent to our servers - everything happens locally on your device. This means your passwords are never transmitted over the internet and remain completely private."
},
{
question: "Is PassMaster safe to use?",
answer: "Yes, PassMaster is completely safe. We use industry-standard cryptographic libraries and generate passwords using the Web Crypto API's secure random number generator. Since all processing happens locally in your browser, there's no risk of your passwords being intercepted or stored on our servers."
},
{
question: "Why use symbols and long passwords?",
answer: "Longer passwords with a mix of character types (uppercase, lowercase, numbers, symbols) significantly increase the time it would take for attackers to crack them. Each additional character and character type exponentially increases the number of possible combinations, making your passwords much more secure against brute force attacks."
},
{
question: "What is client-side encryption?",
answer: "Client-side encryption means that all cryptographic operations happen in your web browser, not on our servers. Your password generation settings, the generated passwords, and any temporary data never leave your device. This ensures maximum privacy and security since we never have access to your passwords."
},
{
question: "Can I use PassMaster offline?",
answer: "Yes! PassMaster is a Progressive Web App (PWA) that can be installed on your device. Once installed, you can generate passwords even without an internet connection. The app will work completely offline, maintaining all its security features."
},
{
question: "How do I know my passwords are truly random?",
answer: "PassMaster uses the Web Crypto API's getRandomValues() function, which provides cryptographically secure random numbers. This is the same technology used by banks and security applications. The randomness is generated by your device's hardware and operating system, ensuring high-quality entropy."
},
{
question: "What does 'exclude similar characters' mean?",
answer: "This option excludes characters that look similar and could be confused with each other, such as 0 (zero) and O (letter O), 1 (one) and l (lowercase L), or I (uppercase i) and l (lowercase L). This helps prevent confusion when typing passwords manually."
},
{
question: "How is password strength calculated?",
answer: "Password strength is calculated using entropy, which measures the randomness and unpredictability of the password. The calculation considers the character set size and password length. Higher entropy means the password is harder to crack. We also estimate the time it would take for a computer to brute force the password."
}
]
export function FAQ() {
const [openItems, setOpenItems] = useState<Set<number>>(new Set())
const toggleItem = (index: number) => {
const newOpenItems = new Set(openItems)
if (newOpenItems.has(index)) {
newOpenItems.delete(index)
} else {
newOpenItems.add(index)
}
setOpenItems(newOpenItems)
}
return (
<div className="space-y-4">
{faqData.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
className="card"
>
<button
onClick={() => toggleItem(index)}
className="w-full flex items-center justify-between text-left focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 rounded-lg p-4"
aria-expanded={openItems.has(index)}
aria-controls={`faq-answer-${index}`}
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white pr-4">
{item.question}
</h3>
<div className="flex-shrink-0">
{openItems.has(index) ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</div>
</button>
<AnimatePresence>
{openItems.has(index) && (
<motion.div
id={`faq-answer-${index}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-4 pb-4">
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{item.answer}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
{/* JSON-LD Schema for FAQ */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqData.map((item, index) => ({
"@type": "Question",
"name": item.question,
"acceptedAnswer": {
"@type": "Answer",
"text": item.answer
}
}))
})
}}
/>
</div>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Key } from 'lucide-react'
export function FloatingCTA() {
const [showFloatingCTA, setShowFloatingCTA] = useState(false)
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// Show floating CTA when user has scrolled past the hero section and generator is not in view
const shouldShow = scrollY > windowHeight * 0.5 && scrollY < documentHeight - windowHeight * 0.3
setShowFloatingCTA(shouldShow)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToGenerator = () => {
const generatorElement = document.getElementById('generator')
if (generatorElement) {
generatorElement.scrollIntoView({ behavior: 'smooth' })
}
}
return (
<AnimatePresence>
{showFloatingCTA && (
<motion.div
className="fixed bottom-6 left-6 z-50"
initial={{ opacity: 0, x: -100, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -100, scale: 0.8 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<motion.button
onClick={scrollToGenerator}
className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-full shadow-lg flex items-center space-x-2 transition-colors duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="Generate password"
>
<Key className="h-5 w-5" />
<span className="font-medium">Generate Password</span>
</motion.button>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Download, Smartphone } from 'lucide-react';
export function PWAInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
useEffect(() => {
// Check if PWA is already installed
const isInstalled = window.matchMedia('(display-mode: standalone)').matches;
if (isInstalled) {
return;
}
// Listen for beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
}
setDeferredPrompt(null);
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
setDeferredPrompt(null);
};
if (!showPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<Download className="h-6 w-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Install PassMaster
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Get quick access to secure password generation
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex space-x-2">
<button
onClick={handleInstall}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium py-2 px-3 rounded-md transition-colors"
>
Install App
</button>
<button
onClick={handleDismiss}
className="flex-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium py-2 px-3 rounded-md transition-colors"
>
Not Now
</button>
</div>
<div className="mt-2 flex items-center text-xs text-gray-500 dark:text-gray-400">
<Smartphone className="h-3 w-3 mr-1" />
Works offline No ads Free forever
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
"use client"
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Copy,
Check,
Eye,
EyeOff,
RefreshCw,
Info,
Key
} from 'lucide-react'
import { generatePassword, calculateEntropy, estimateTimeToCrack } from '@/utils/passwordGenerator'
interface PasswordOptions {
length: number
includeUppercase: boolean
includeLowercase: boolean
includeNumbers: boolean
includeSymbols: boolean
excludeSimilar: boolean
}
export function PasswordGenerator() {
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(true)
const [copied, setCopied] = useState(false)
const [options, setOptions] = useState<PasswordOptions>({
length: 16,
includeUppercase: true,
includeLowercase: true,
includeNumbers: true,
includeSymbols: true,
excludeSimilar: false,
})
// Load settings from localStorage on mount
useEffect(() => {
const savedOptions = localStorage.getItem('passmaster-settings')
if (savedOptions) {
try {
const parsed = JSON.parse(savedOptions)
setOptions(prev => ({ ...prev, ...parsed }))
} catch (error) {
console.error('Failed to load saved settings:', error)
}
}
}, [])
// Save settings to localStorage when options change
useEffect(() => {
localStorage.setItem('passmaster-settings', JSON.stringify(options))
}, [options])
const handleGenerate = () => {
const newPassword = generatePassword(options)
setPassword(newPassword)
setCopied(false)
}
const handleCopy = async () => {
if (password) {
try {
await navigator.clipboard.writeText(password)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Failed to copy password:', error)
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = password
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
}
const getStrengthLevel = (entropy: number) => {
if (entropy < 40) return { level: 'Weak', color: 'strength-weak', bg: 'bg-red-500' }
if (entropy < 60) return { level: 'OK', color: 'strength-ok', bg: 'bg-yellow-500' }
if (entropy < 80) return { level: 'Strong', color: 'strength-strong', bg: 'bg-blue-500' }
return { level: 'Excellent', color: 'strength-excellent', bg: 'bg-green-500' }
}
const entropy = password ? calculateEntropy(password) : 0
const timeToCrack = password ? estimateTimeToCrack(password) : ''
const strength = getStrengthLevel(entropy)
return (
<div className="card max-w-2xl mx-auto">
{/* Generated Password */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Generated Password
</label>
<div className="flex space-x-2">
<div className="flex-1 relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
readOnly
className="input-field font-mono text-lg"
placeholder="Click 'Generate Password' to create a secure password"
aria-label="Generated password"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<motion.button
onClick={handleCopy}
disabled={!password}
className="px-4 py-3 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors duration-200"
whileHover={{ scale: password ? 1.05 : 1 }}
whileTap={{ scale: password ? 0.95 : 1 }}
aria-label="Copy password"
>
<AnimatePresence mode="wait">
{copied ? (
<motion.div
key="check"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.2 }}
>
<Check className="h-4 w-4" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.2 }}
>
<Copy className="h-4 w-4" />
</motion.div>
)}
</AnimatePresence>
<span>{copied ? 'Copied!' : 'Copy'}</span>
</motion.button>
</div>
{/* Password Strength */}
{password && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Strength:</span>
<span className={`font-medium ${strength.color.replace('strength-', 'text-')}`}>
{strength.level}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<motion.div
className={`strength-meter ${strength.color}`}
initial={{ width: 0 }}
animate={{ width: `${Math.min((entropy / 100) * 100, 100)}%` }}
transition={{ duration: 0.5 }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Entropy: {entropy.toFixed(1)} bits</span>
<span>Time to crack: {timeToCrack}</span>
</div>
</div>
)}
</div>
{/* Options */}
<div className="space-y-6">
{/* Length Slider */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password Length: {options.length}
</label>
<input
type="range"
min="8"
max="128"
value={options.length}
onChange={(e) => setOptions({ ...options, length: parseInt(e.target.value) })}
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer slider"
/>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
<span>8</span>
<span>128</span>
</div>
</div>
{/* Character Options */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">Character Types</h3>
{[
{ key: 'includeUppercase', label: 'Uppercase (A-Z)' },
{ key: 'includeLowercase', label: 'Lowercase (a-z)' },
{ key: 'includeNumbers', label: 'Numbers (0-9)' },
{ key: 'includeSymbols', label: 'Symbols (!@#$%^&*)' },
].map(({ key, label }) => (
<label key={key} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={options[key as keyof PasswordOptions] as boolean}
onChange={(e) => setOptions({ ...options, [key]: e.target.checked })}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
</label>
))}
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">Options</h3>
<label className="flex items-start space-x-2 cursor-pointer">
<input
type="checkbox"
checked={options.excludeSimilar}
onChange={(e) => setOptions({ ...options, excludeSimilar: e.target.checked })}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 mt-0.5"
/>
<div className="flex-1">
<span className="text-sm text-gray-700 dark:text-gray-300">Exclude Similar Characters</span>
<div className="flex items-center space-x-1 mt-1">
<Info className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-500 dark:text-gray-400">
Excludes 0/O, l/I, 1/I to avoid confusion
</span>
</div>
</div>
</label>
</div>
</div>
</div>
{/* Generate Button */}
<motion.button
onClick={handleGenerate}
className="w-full btn-primary mt-6 flex items-center justify-center space-x-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<RefreshCw className="h-5 w-5" />
<span>Generate Password</span>
</motion.button>
{/* ARIA Live Region for Copy Feedback */}
<div aria-live="polite" className="sr-only">
{copied && 'Password copied to clipboard'}
</div>
</div>
)
}

View File

@@ -0,0 +1,108 @@
"use client"
import { Shield, Github, Heart } from 'lucide-react'
import { motion } from 'framer-motion'
export function Footer() {
return (
<footer className="bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Brand */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Shield className="h-6 w-6 text-primary-600" />
<span className="text-lg font-bold text-gray-900 dark:text-white">
PassMaster
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs">
Generate ultra-secure passwords instantly, offline with client-side encryption.
100% open-source, private, and free.
</p>
</div>
{/* Quick Links */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider">
Quick Links
</h3>
<ul className="space-y-2">
<li>
<a
href="#generator"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
>
Password Generator
</a>
</li>
<li>
<a
href="#faq"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
>
FAQ
</a>
</li>
<li>
<a
href="/privacy"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
>
Privacy Policy
</a>
</li>
<li>
<a
href="/offline-test"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
>
Offline Test
</a>
</li>
</ul>
</div>
{/* Open Source */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider">
Open Source
</h3>
<div className="space-y-2">
<a
href="https://github.com/your-username/passmaster"
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
>
<Github className="h-4 w-4" />
<span>View on GitHub</span>
</a>
<p className="text-xs text-gray-500 dark:text-gray-500">
Licensed under MIT
</p>
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p className="text-sm text-gray-600 dark:text-gray-400">
© {new Date().getFullYear()} PassMaster. All rights reserved.
</p>
<motion.div
className="flex items-center space-x-1 text-sm text-gray-600 dark:text-gray-400"
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
>
<span>Made with</span>
<Heart className="h-4 w-4 text-red-500" />
<span>for privacy</span>
</motion.div>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,149 @@
"use client"
import { useState, useEffect } from 'react'
import { Shield, Sun, Moon, Download, Menu, X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { motion } from 'framer-motion'
import Link from 'next/link'
export function Header() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [showInstallPrompt, setShowInstallPrompt] = useState(false)
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
useEffect(() => {
setMounted(true)
// Listen for PWA install prompt
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
setDeferredPrompt(e)
setShowInstallPrompt(true)
})
}, [])
const handleInstallClick = async () => {
if (deferredPrompt) {
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
setShowInstallPrompt(false)
setDeferredPrompt(null)
}
}
}
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark')
}
if (!mounted) {
return null
}
return (
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<motion.div
className="flex items-center space-x-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
<Link href="/" className="flex items-center space-x-2 hover:opacity-80 transition-opacity">
<Shield className="h-8 w-8 text-primary-600" />
<span className="text-xl font-bold text-gray-900 dark:text-white">
PassMaster
</span>
</Link>
</motion.div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-4">
<Link
href="/privacy"
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200 px-3 py-2 rounded-md text-sm font-medium"
>
Privacy
</Link>
{showInstallPrompt && (
<motion.button
onClick={handleInstallClick}
className="btn-secondary flex items-center space-x-2"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<Download className="h-4 w-4" />
<span>Install App</span>
</motion.button>
)}
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200"
aria-label="Toggle theme"
>
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
</nav>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200"
aria-label="Toggle mobile menu"
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<motion.div
className="md:hidden py-4 border-t border-gray-200 dark:border-gray-700"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex flex-col space-y-3">
<Link
href="/privacy"
className="flex items-center justify-center px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200"
onClick={() => setMobileMenuOpen(false)}
>
Privacy Policy
</Link>
{showInstallPrompt && (
<button
onClick={handleInstallClick}
className="btn-secondary flex items-center justify-center space-x-2"
>
<Download className="h-4 w-4" />
<span>Install App</span>
</button>
)}
<button
onClick={toggleTheme}
className="flex items-center justify-center space-x-2 p-3 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200"
>
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
<span>Toggle Theme</span>
</button>
</div>
</motion.div>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,9 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}