Add .npm-cache to gitignore; users route and mobile header updates

This commit is contained in:
Michilis
2026-02-02 21:16:50 +00:00
parent 4a84ad22c7
commit 9090d7bad2
3 changed files with 241 additions and 62 deletions

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ backend/uploads/
# Tooling # Tooling
.turbo/ .turbo/
.cursor/ .cursor/
.npm-cache/
# OS # OS
.DS_Store .DS_Store

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, events, payments } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql } from 'drizzle-orm'; import { eq, desc, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -191,14 +191,73 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
.where(eq((tickets as any).userId, id)) .where(eq((tickets as any).userId, id))
); );
// Delete payments associated with user's tickets // Delete invoices associated with user's tickets (invoices reference payments which reference tickets)
for (const ticket of userTickets) { for (const ticket of userTickets) {
// Get payments for this ticket
const ticketPayments = await dbAll<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
// Delete invoices for each payment
for (const payment of ticketPayments) {
await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
}
// Delete payments for this ticket
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id)); await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
} }
// Delete invoices directly associated with the user (if any)
await (db as any).delete(invoices).where(eq((invoices as any).userId, id));
// Delete user's tickets // Delete user's tickets
await (db as any).delete(tickets).where(eq((tickets as any).userId, id)); await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
// Delete magic link tokens for the user
await (db as any).delete(magicLinkTokens).where(eq((magicLinkTokens as any).userId, id));
// Delete user sessions
await (db as any).delete(userSessions).where(eq((userSessions as any).userId, id));
// Set userId to null in audit_logs (nullable reference)
await (db as any)
.update(auditLogs)
.set({ userId: null })
.where(eq((auditLogs as any).userId, id));
// Set sentBy to null in email_logs (nullable reference)
await (db as any)
.update(emailLogs)
.set({ sentBy: null })
.where(eq((emailLogs as any).sentBy, id));
// Set updatedBy to null in payment_options (nullable reference)
await (db as any)
.update(paymentOptions)
.set({ updatedBy: null })
.where(eq((paymentOptions as any).updatedBy, id));
// Set updatedBy to null in legal_pages (nullable reference)
await (db as any)
.update(legalPages)
.set({ updatedBy: null })
.where(eq((legalPages as any).updatedBy, id));
// Set updatedBy to null in site_settings (nullable reference)
await (db as any)
.update(siteSettings)
.set({ updatedBy: null })
.where(eq((siteSettings as any).updatedBy, id));
// Clear checkedInByAdminId references in tickets
await (db as any)
.update(tickets)
.set({ checkedInByAdminId: null })
.where(eq((tickets as any).checkedInByAdminId, id));
// Delete the user // Delete the user
await (db as any).delete(users).where(eq((users as any).id, id)); await (db as any).delete(users).where(eq((users as any).id, id));

View File

@@ -2,14 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle'; import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
function NavLink({ href, children }: { href: string; children: React.ReactNode }) { function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -33,7 +32,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
return ( return (
<Link <Link
href={href} href={href}
className="px-4 py-2 hover:bg-gray-50 rounded-lg font-medium" className="block px-6 py-3 text-lg font-medium transition-colors hover:bg-gray-50"
style={{ color: isActive ? '#FBB82B' : '#002F44' }} style={{ color: isActive ? '#FBB82B' : '#002F44' }}
onClick={onClick} onClick={onClick}
> >
@@ -46,6 +45,65 @@ export default function Header() {
const { t } = useLanguage(); const { t } = useLanguage();
const { user, isAdmin, logout } = useAuth(); const { user, isAdmin, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
const touchCurrentX = useRef<number>(0);
const isDragging = useRef<boolean>(false);
// Close menu on route change
const pathname = usePathname();
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
// Prevent body scroll when menu is open
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [mobileMenuOpen]);
// Handle swipe to close
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchCurrentX.current = e.touches[0].clientX;
isDragging.current = true;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging.current) return;
touchCurrentX.current = e.touches[0].clientX;
const deltaX = touchCurrentX.current - touchStartX.current;
// Only allow dragging to the right (to close)
if (deltaX > 0 && menuRef.current) {
menuRef.current.style.transform = `translateX(${deltaX}px)`;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (!isDragging.current) return;
isDragging.current = false;
const deltaX = touchCurrentX.current - touchStartX.current;
const threshold = 100; // Minimum swipe distance to close
if (menuRef.current) {
menuRef.current.style.transform = '';
if (deltaX > threshold) {
setMobileMenuOpen(false);
}
}
}, []);
const closeMenu = useCallback(() => {
setMobileMenuOpen(false);
}, []);
const navLinks = [ const navLinks = [
{ href: '/', label: t('nav.home') }, { href: '/', label: t('nav.home') },
@@ -118,81 +176,142 @@ export default function Header() {
)} )}
</div> </div>
{/* Mobile menu button */} {/* Mobile menu button (hamburger) */}
<button <button
className="md:hidden p-2 rounded-lg hover:bg-gray-100" className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
> >
{mobileMenuOpen ? ( <Bars3Icon className="w-6 h-6" style={{ color: '#002F44' }} />
<XMarkIcon className="w-6 h-6" /> </button>
) : ( </div>
<Bars3Icon className="w-6 h-6" /> </nav>
)}
{/* Mobile Slide-in Menu */}
{/* Overlay */}
<div
className={`
fixed inset-0 bg-black/50 z-40 md:hidden
transition-opacity duration-300 ease-in-out
${mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
onClick={closeMenu}
aria-hidden="true"
/>
{/* Slide-in Panel */}
<div
ref={menuRef}
className={`
fixed top-0 right-0 h-full w-[280px] max-w-[85vw] bg-white z-50 md:hidden
shadow-xl transform transition-transform duration-300 ease-in-out
${mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}
`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Menu Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<Image
src="/images/logo-spanglish.png"
alt="Spanglish"
width={100}
height={28}
className="h-7 w-auto"
/>
<button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={closeMenu}
aria-label="Close menu"
>
<XMarkIcon className="w-6 h-6" style={{ color: '#002F44' }} />
</button> </button>
</div> </div>
{/* Mobile Navigation */} {/* Menu Content */}
<div <div className="flex flex-col h-[calc(100%-65px)] overflow-y-auto">
className={clsx( {/* Navigation Links */}
'md:hidden overflow-hidden transition-all duration-300', <nav className="py-4">
{
'max-h-0': !mobileMenuOpen,
'max-h-96 pb-4': mobileMenuOpen,
}
)}
>
<div className="flex flex-col gap-2 pt-4">
{navLinks.map((link) => ( {navLinks.map((link) => (
<MobileNavLink <MobileNavLink
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={() => setMobileMenuOpen(false)} onClick={closeMenu}
> >
{link.label} {link.label}
</MobileNavLink> </MobileNavLink>
))} ))}
</nav>
<div className="border-t border-gray-100 mt-2 pt-4 px-4"> {/* Divider */}
<div className="border-t border-gray-100 mx-6" />
{/* Language Toggle */}
<div className="px-6 py-4">
<LanguageToggle variant="buttons" /> <LanguageToggle variant="buttons" />
</div> </div>
<div className="px-4 pt-2 flex flex-col gap-2"> {/* Divider */}
<div className="border-t border-gray-100 mx-6" />
{/* Auth Actions */}
<div className="px-6 py-4 flex flex-col gap-3 mt-auto">
{user ? ( {user ? (
<> <>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}> <div className="text-sm text-gray-500 mb-2 flex items-center gap-2">
<Button variant="outline" className="w-full"> <div className="w-8 h-8 rounded-full bg-[#002F44] flex items-center justify-center text-white text-sm font-medium">
{user.name?.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-[#002F44]">{user.name}</span>
</div>
<Link href="/dashboard" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center">
{t('nav.dashboard')} {t('nav.dashboard')}
</Button> </Button>
</Link> </Link>
{isAdmin && ( {isAdmin && (
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}> <Link href="/admin" onClick={closeMenu}>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full justify-center">
{t('nav.admin')} {t('nav.admin')}
</Button> </Button>
</Link> </Link>
)} )}
<Button variant="secondary" onClick={logout} className="w-full"> <Button
variant="secondary"
onClick={() => {
logout();
closeMenu();
}}
className="w-full justify-center"
>
{t('nav.logout')} {t('nav.logout')}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Link href="/login" onClick={() => setMobileMenuOpen(false)}> <Link href="/login" onClick={closeMenu}>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full justify-center">
{t('nav.login')} {t('nav.login')}
</Button> </Button>
</Link> </Link>
<Link href="/events" onClick={() => setMobileMenuOpen(false)}> <Link href="/events" onClick={closeMenu}>
<Button className="w-full"> <Button className="w-full justify-center">
{t('nav.joinEvent')} {t('nav.joinEvent')}
</Button> </Button>
</Link> </Link>
</> </>
)} )}
</div> </div>
{/* Swipe hint */}
<div className="px-6 pb-6 pt-2">
<p className="text-xs text-gray-400 text-center">
Swipe right to close
</p>
</div>
</div> </div>
</div> </div>
</nav>
</header> </header>
); );
} }