diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 7248ace..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,111 +0,0 @@ -# Vibely — Agent Instructions - -## Project Overview - -Vibely is an event-centric photo sharing platform. Hosts create events and share -QR codes. Guests upload photos without creating an account. Photos auto-expire -after events end unless saved to a personal vault. - -## Monorepo Structure - -- `apps/vibely-web` — Next.js 14 App Router (web frontend + ALL API routes) -- `apps/vibely-mobile` — Expo React Native app -- `packages/shared` — Shared TypeScript types, Zod schemas, utility functions -- `supabase/migrations/` — Numbered SQL migration files (001–005 exist) -- `supabase/functions/` — Supabase Edge Functions (Deno runtime) - -## Backend Scope for Jules - -Jules ONLY works on: - -- `supabase/migrations/` — new migration files -- `supabase/functions/` — new or updated Edge Functions -- `apps/vibely-web/app/api/` — Next.js API route handlers -- `apps/vibely-web/lib/` — server-side utilities -- `apps/vibely-web/hooks/` — data fetching hooks (no UI) -- `packages/shared/` — types, schemas, utilities - -Jules NEVER modifies: - -- `apps/vibely-web/app/` page files (anything not inside `api/`) -- `apps/vibely-web/components/` -- `apps/vibely-mobile/` (entire directory) -- `apps/vibely-web/app/layout.tsx` -- `apps/vibely-web/proxy.ts` - -## Tech Stack - -- Database: Supabase (PostgreSQL) with Row Level Security on all tables -- Auth: Supabase Auth (JWT Bearer tokens) -- Storage: Supabase Storage (event-photos, avatars, event-covers buckets) -- CDN: ImageKit (URL endpoint in NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT) -- Rate limiting: Upstash Redis (see apps/vibely-web/lib/rate-limit.ts) -- Serverless: Next.js API routes on Vercel -- Edge Functions: Deno runtime (supabase/functions/) - -## Authentication Pattern - -Every authenticated API route uses this exact pattern: - -```ts -import { createClient, createAdminClient } from "@/lib/supabase/server"; -const supabase = await createClient(); -const { - data: { user }, -} = await supabase.auth.getUser(); -if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); -// Use adminSupabase for queries that need to bypass RLS -const adminSupabase = createAdminClient(); -``` - -## API Route Conventions - -- All routes: `apps/vibely-web/app/api/{resource}/route.ts` -- Nested: `apps/vibely-web/app/api/{resource}/[id]/route.ts` -- Always validate request body with Zod before processing -- Return `{ error: string }` for errors with appropriate HTTP status -- Return data directly (not wrapped in `{ data: ... }`) for success -- Use `createAdminClient()` for writes and RLS-bypassing reads -- Use `createClient()` for reads that respect RLS - -## Database Conventions - -- All tables have RLS enabled -- UUIDs as primary keys (`gen_random_uuid()`) -- `created_at TIMESTAMPTZ NOT NULL DEFAULT now()` -- Soft deletes via `status = 'deleted'` and `deleted_at` (not hard DELETE) -- Migration files are numbered sequentially: 006_feature_name.sql - -## Shared Package - -- Import types: `import type { Event, Photo } from '@repo/shared/types'` -- Import schemas: `import { someSchema } from '@repo/shared/validation/...'` -- Import utils: `import { thumbnailUrl } from '@repo/shared/utils/storage'` -- When adding new types/schemas, add to the appropriate file in packages/shared/ - -## Storage Key Patterns - -- Photos: `events/{eventId}/{photoId}/{filename}` -- Avatars: `{userId}/avatar.{ext}` (fixed name — overwrites on update) -- Covers: `{eventId}/cover.{ext}` (fixed name — overwrites on update) - -## Rate Limiting - -Use pre-configured limiters from `apps/vibely-web/lib/rate-limit.ts`. -Add new limiters there if needed. Always check `if (!rl.success) return rl.response!;` - -## ImageKit URL Helpers - -Use helpers from `packages/shared/utils/storage.ts`: - -- `thumbnailUrl(storageKey)` — 400px thumbnail -- `previewUrl(storageKey)` — 1200px preview -- `fullUrl(storageKey)` — full quality - -## What Good PRs Look Like - -- One migration file per PR (increment the number) -- API routes include inline comments explaining WHY (not what) -- Zod validation for every request body -- RLS policies in the migration for any new tables -- No `console.log` left in production code (use `console.error` for actual errors only) diff --git a/apps/vibely-web/app/(app)/dashboard/page.tsx b/apps/vibely-web/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..8c66eba --- /dev/null +++ b/apps/vibely-web/app/(app)/dashboard/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useEvents } from "@/hooks/useEvents"; +import { useDashboardStats } from "@/hooks/useDashboardStats"; +import { createClient } from "@/lib/supabase/client"; +import Link from "next/link"; +import EventCard from "@/components/events/EventCard"; + +export default function DashboardPage() { + const { events, isLoading: eventsLoading, error: eventsError } = useEvents(); + const { + totalPhotos, + formattedSize, + isLoading: statsLoading, + } = useDashboardStats(); + const [userName, setUserName] = useState("Host"); + + // Fetch user for the greeting + useEffect(() => { + const supabase = createClient(); + supabase.auth.getUser().then(({ data: { user } }) => { + if (!user) return; + supabase + .from("users") + .select("name") + .eq("id", user.id) + .single() + .then(({ data }) => { + if (data && data.name) { + // Pick first name + setUserName(data.name.split(" ")[0]); + } + }); + }); + }, []); + + const upcomingCount = events.filter( + (e) => new Date(e.event_date) > new Date() + ).length; + + return ( + <> +
+
+

+ Welcome, {userName} +

+

+ Your collective galleries are flourishing with new memories. +

+
+ +
+ +
+
+ + {/* Bento Grid for Stats */} +
+ {/* Photos Stat */} +
+
+
+ + + photo_library + + + + +12% + +
+

+ Total Photos +

+

+ {statsLoading ? "..." : totalPhotos.toLocaleString()} +

+
+ + {/* Storage Stat */} +
+
+
+ + + cloud_done + + +
+

+ Storage Used +

+

+ {statsLoading ? "..." : formattedSize} +

+
+ + {/* Upcoming Events Stat */} +
+
+
+ + event + +
+

+ Upcoming Events +

+

+ {eventsLoading ? "..." : upcomingCount} +

+
+
+ +
+ {/* Active Events Canvas */} +
+

+ Active Galleries +

+ + See all archives + + arrow_forward + + +
+ + {eventsError && ( +
+ {eventsError} +
+ )} + + {!eventsLoading && events.length === 0 ? ( + /* Empty State */ +
+
+
+
+ + auto_awesome + +
+

+ No Events Yet +

+

+ Ready to start capturing memories? Your first gallery is just a + few clicks away. +

+ + Create First Event + +
+ ) : ( + /* Events Grid - Redesigned with Glassmorphism */ +
+ {eventsLoading + ? Array.from({ length: 3 }).map((_, i) => ( +
+ )) + : events.map((event) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/apps/vibely-web/app/events/[id]/edit/page.tsx b/apps/vibely-web/app/(app)/events/[id]/edit/page.tsx similarity index 100% rename from apps/vibely-web/app/events/[id]/edit/page.tsx rename to apps/vibely-web/app/(app)/events/[id]/edit/page.tsx diff --git a/apps/vibely-web/app/(app)/events/[id]/page.tsx b/apps/vibely-web/app/(app)/events/[id]/page.tsx new file mode 100644 index 0000000..a3f12e1 --- /dev/null +++ b/apps/vibely-web/app/(app)/events/[id]/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +// ============================================================ +// apps/web/app/(app)/events/[id]/page.tsx +// ============================================================ + +import { use, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEvent } from "@/hooks/useEvents"; +import { usePhotos } from "@/hooks/usePhotos"; +import { isEventExpired } from "@shared/utils/invite"; + +import { EventHero } from "@/components/events/EventHero"; +import { EventTabs, EventTabId } from "@/components/events/EventTabs"; +import { EventActionBar } from "@/components/events/EventActionBar"; +import { EventGallery } from "@/components/events/EventGallery"; +import { EventQRPanel } from "@/components/events/EventQRPanel"; +import { EventStatsCard } from "@/components/events/EventStatsCard"; +import { PhotoUploader } from "@/components/photos/PhotoUploader"; +import { EditEventForm } from "@/components/events/EditEventForm"; + +export type EventPageProps = { params: Promise<{ id: string }> }; + +export default function EventDetailPage({ params }: EventPageProps) { + const { id } = use(params); + const router = useRouter(); + + const { + event, + userRole, + isLoading: eventLoading, + error: eventError, + updateEvent, + deleteEvent, + } = useEvent(id); + + const { + photos, + pagination, + isLoading: photosLoading, + uploads, + fetchPage, + uploadFiles, + savePhoto, + unsavePhoto, + } = usePhotos(id); + + const [activeTab, setActiveTab] = useState("gallery"); + const [showUploader, setShowUploader] = useState(false); + + if (eventLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (eventError || !event) { + return ( +
+
+

+ Event Not Found +

+

+ {eventError ?? + "The event you are looking for doesn't exist or you don't have access."} +

+ + Back to Dashboard + +
+
+ ); + } + + const isHost = userRole === "host"; + const expired = event.expires_at ? isEventExpired(event.expires_at) : false; + const canUpload = !expired && event.status === "active"; + + return ( +
+ {/* Header: Hero Section */} + + +
+ {/* Left Column: Tabs & Gallery */} +
+ + + {activeTab === "gallery" && ( + <> + console.log("Bulk Select clicked")} + onDownloadAll={() => console.log("Download All clicked")} + onUpload={() => setShowUploader(!showUploader)} + /> + + {canUpload && showUploader && ( +
+ +
+ )} + + {expired && ( +
+ This event has ended. Photos are view-only. Save any photos to + your vault before they expire. +
+ )} + + router.push(`/photos/${photo.id}`)} + onSavePhoto={savePhoto} + onUnsavePhoto={unsavePhoto} + /> + + )} + + {activeTab === "guests" && ( +
+
+

Guest List

+ + {event.event_members?.length ?? 0} total + +
+
+ {event.event_members?.map((member) => ( +
+
+ {member.user?.name + ? member.user.name.charAt(0).toUpperCase() + : "?"} +
+
+

+ {member.user?.name ?? "Unknown Guest"} +

+

+ Joined {new Date(member.joined_at).toLocaleDateString()} +

+
+
+ + {member.role === "host" ? "Host" : "Guest"} + +
+
+ ))} +
+
+ )} + + {activeTab === "settings" && ( +
+
+

+ Event Settings +

+

+ Update your event details, change visibility, or delete this + event. +

+
+ +
+ {isHost ? ( + + ) : ( +
+ + lock + +

Only the event host can modify these settings.

+
+ )} +
+
+ )} +
+ + {/* Right Column: Sidebar Widgets */} + +
+
+ ); +} diff --git a/apps/vibely-web/app/events/create/page.tsx b/apps/vibely-web/app/(app)/events/create/page.tsx similarity index 100% rename from apps/vibely-web/app/events/create/page.tsx rename to apps/vibely-web/app/(app)/events/create/page.tsx diff --git a/apps/vibely-web/app/(app)/layout.tsx b/apps/vibely-web/app/(app)/layout.tsx new file mode 100644 index 0000000..4fda383 --- /dev/null +++ b/apps/vibely-web/app/(app)/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from "react"; +import AppLayout from "@/components/layout/AppLayout"; + +export default function AppGroupLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/vibely-web/app/photos/[id]/page.tsx b/apps/vibely-web/app/(app)/photos/[id]/page.tsx similarity index 100% rename from apps/vibely-web/app/photos/[id]/page.tsx rename to apps/vibely-web/app/(app)/photos/[id]/page.tsx diff --git a/apps/vibely-web/app/profile/page.tsx b/apps/vibely-web/app/(app)/profile/page.tsx similarity index 100% rename from apps/vibely-web/app/profile/page.tsx rename to apps/vibely-web/app/(app)/profile/page.tsx diff --git a/apps/vibely-web/app/(app)/vault/page.tsx b/apps/vibely-web/app/(app)/vault/page.tsx new file mode 100644 index 0000000..3be22a0 --- /dev/null +++ b/apps/vibely-web/app/(app)/vault/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +// ============================================================ +// apps/web/app/(app)/vault/page.tsx +// ============================================================ + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useVault } from "@/hooks/useVault"; +import VaultCard from "@/components/vault/VaultCard"; +import VaultFilters from "@/components/vault/VaultFilters"; + +export default function VaultPage() { + const router = useRouter(); + const { groups, total, isLoading, error, unsave } = useVault(); + + // Flatten the groups into a single array of photos for the masonry grid + const allPhotos = groups.flatMap((group) => + group.photos.map((photo) => ({ + ...photo, + event: group.event, + })) + ); + + return ( + <> + {/* Header & Filter Pills */} +
+
+
+
+

+ Personal Archive +

+

+ A curated collection of your most precious moments, synchronized + across all your hosted events. +

+
+
+ + +
+
+ + {/* Loading State */} + {isLoading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ )} + + {/* Error State */} + {error && !isLoading && ( +
+ {error} +
+ )} + + {/* Empty State */} + {!isLoading && !error && total === 0 && ( +
+
+
+
+ + star + +
+

+ Your Vault is Empty +

+

+ Save photos from your events by hovering over them and clicking the + star icon. +

+ + Browse Events + +
+ )} + + {/* Masonry Gallery */} + {!isLoading && allPhotos.length > 0 && ( +
+ {allPhotos.map((entry) => ( + unsave(entry.photo.id)} + onOpen={() => router.push(`/photos/${entry.photo.id}`)} + /> + ))} +
+ )} + + {/* Pagination or Load More - only visible if we have photos */} + {!isLoading && allPhotos.length > 0 && ( +
+ +
+ )} + + ); +} diff --git a/apps/vibely-web/app/(auth)/login/page.tsx b/apps/vibely-web/app/(auth)/login/page.tsx index 33a2356..ec8e4ac 100644 --- a/apps/vibely-web/app/(auth)/login/page.tsx +++ b/apps/vibely-web/app/(auth)/login/page.tsx @@ -3,22 +3,13 @@ // ============================================================ // apps/web/app/(auth)/login/page.tsx // ============================================================ -// Neumorphic Login page preserving existing Supabase auth flow. +// Glassmorphism Login page preserving existing Supabase auth flow. // ============================================================ import { Suspense, useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; -import { - Zap, - Mail, - Lock, - Eye, - EyeOff, - ArrowRight, - Terminal, -} from "lucide-react"; function LoginForm() { const router = useRouter(); @@ -29,7 +20,6 @@ function LoginForm() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const [showPassword, setShowPassword] = useState(false); const redirectTo = searchParams.get("redirectTo") ?? "/dashboard"; const callbackError = searchParams.get("error"); @@ -52,6 +42,12 @@ function LoginForm() { const handleEmailLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + + if (!email.trim() || !password.trim()) { + setError("Please enter both your email and password."); + return; + } + setIsSubmitting(true); const result = await signIn(email, password); @@ -77,183 +73,207 @@ function LoginForm() { if (isLoading) return null; return ( -
-
- {/* Main Neumorphic Card */} -
- {/* Header Section */} -
-
-
- -
+
+ {/* Abstract Background Elements */} +
+
+ + {/* Top Navigation Bar */} +
+ + Vibely + +
+ + help_outline + +
+
+ +
+ {/* Centered Neumorphic Login Card */} +
+ {/* Branding & Greeting */} +
+
+ + auto_awesome +
-

+

Welcome Back

-

- Log in to your vault +

+ Enter your details to access your curated gallery.

- {/* Form Section */} -
+ {/* Login Form */} + {/* Error banner */} {error && ( -
+
{error}
)} - {/* Email Input */} -
-
- {/* Social Login Hint */} -
-
-
- - Or connect with - -
-
- -
- -
+ {/* Footer */} +
+
+ + Privacy Policy + + + Terms of Service + + + Support +
-
+
+ © 2024 VIBELY CREATIVE SYSTEMS +
+ + + {/* Decorative Image Overlays (Invisible but present for structure as per mandate) */} +
+
); } export default function LoginPage() { return ( - }> + }> ); diff --git a/apps/vibely-web/app/(auth)/signup/page.tsx b/apps/vibely-web/app/(auth)/signup/page.tsx index d0de26a..91dd0ad 100644 --- a/apps/vibely-web/app/(auth)/signup/page.tsx +++ b/apps/vibely-web/app/(auth)/signup/page.tsx @@ -10,7 +10,6 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; -import { Zap, Eye, EyeOff } from "lucide-react"; export default function SignupPage() { const router = useRouter(); @@ -36,6 +35,16 @@ export default function SignupPage() { setError(""); setSuccessMessage(""); + if ( + !name.trim() || + !email.trim() || + !password.trim() || + !confirmPassword.trim() + ) { + setError("Please fill in all fields."); + return; + } + if (name.trim().length < 2) { setError("Name must be at least 2 characters."); return; @@ -68,180 +77,232 @@ export default function SignupPage() { if (isLoading) return null; return ( -
-
- {/* Neumorphic Card */} -
- {/* Logo/Icon */} -
- +
+
+ {/* Left Side: Editorial Abstract Section */} +
+
+ + Vibely + +

+ Preserve every
+ shared moment. +

+

+ Join the private gallery where memories are suspended in + high-fidelity digital keepsakes. +

- {/* Header Section */} -
-

- Join Vibely -

-

- Start capturing memories -

-
+ {/* Abstract Gradient Decoration */} +
+
- {/* Form Section */} -
- {error && ( -
- {error} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + User 1 + {/* eslint-disable-next-line @next/next/no-img-element */} + User 2 + {/* eslint-disable-next-line @next/next/no-img-element */} + User 3 +
+ +2k
- )} +
+ + Trusted by creators worldwide + +
+
- {successMessage && ( -
- {successMessage} -
- )} + {/* Right Side: Signup Form Section */} +
+
+ + Vibely + + + Login + +
- {!successMessage && ( - <> - {/* Full Name Input */} -
- - setName(e.target.value)} - required - className="shadow-neo-inset rounded-xl bg-background-light w-full h-12 px-5 text-slate-800 placeholder:text-slate-400 font-medium border-none outline-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 transition-all [&:-webkit-autofill]:shadow-[0_0_0px_1000px_#E8EDF2_inset] [&:-webkit-autofill]:[-webkit-text-fill-color:#1e293b]" - placeholder="Enter your full name" - /> +
+
+

+ Create Account +

+

+ Enter your details to start your journey. +

+
+ + + {error && ( +
+ {error}
+ )} - {/* Email Input */} -
- - setEmail(e.target.value)} - required - className="shadow-neo-inset rounded-xl bg-background-light w-full h-12 px-5 text-slate-800 placeholder:text-slate-400 font-medium border-none outline-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 transition-all [&:-webkit-autofill]:shadow-[0_0_0px_1000px_#E8EDF2_inset] [&:-webkit-autofill]:[-webkit-text-fill-color:#1e293b]" - placeholder="email@example.com" - /> + {successMessage && ( +
+ {successMessage}
+ )} + + {!successMessage && ( + <> +
+ +
+ + person + + setName(e.target.value)} + required + /> +
+
+ +
+ +
+ + mail + + setEmail(e.target.value)} + required + /> +
+
- {/* Password Input */} -
- -
- setPassword(e.target.value)} - required - className="shadow-neo-inset rounded-xl bg-background-light w-full h-12 px-5 pr-12 text-slate-800 placeholder:text-slate-400 font-medium tracking-wide border-none outline-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 transition-all [&:-webkit-autofill]:shadow-[0_0_0px_1000px_#E8EDF2_inset] [&:-webkit-autofill]:[-webkit-text-fill-color:#1e293b]" - placeholder="Create a password" - /> +
+ +
+ + lock + + setPassword(e.target.value)} + required + /> + +
+
+ +
+ +
+ + lock + + setConfirmPassword(e.target.value)} + required + /> +
+
+ +
-
- - {/* Confirm Password Input */} -
- - setConfirmPassword(e.target.value)} - required - className="shadow-neo-inset rounded-xl bg-background-light w-full h-12 px-5 text-slate-800 placeholder:text-slate-400 font-medium tracking-wide border-none outline-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 transition-all [&:-webkit-autofill]:shadow-[0_0_0px_1000px_#E8EDF2_inset] [&:-webkit-autofill]:[-webkit-text-fill-color:#1e293b]" - placeholder="••••••••" - /> -
+ + )} + - {/* Disclaimer */} -

- By signing up, you agree to our{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . -

- - {/* Action Button */} - - - )} - - - {/* Bottom Link */} -
-

- Already have an account?{" "} - - Log in - -

+ Login + +

+

+ By signing up, you agree to our + + Terms of Service + + and + + Privacy Policy + + . +

+
- - {/* Footer Info */} -
- © {new Date().getFullYear()} Vibely Inc. All rights reserved. -
-
+
); } diff --git a/apps/vibely-web/app/api/dashboard/stats/route.ts b/apps/vibely-web/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..8524716 --- /dev/null +++ b/apps/vibely-web/app/api/dashboard/stats/route.ts @@ -0,0 +1,76 @@ +// ============================================================ +// apps/vibely-web/app/api/dashboard/stats/route.ts +// ============================================================ +// GET /api/dashboard/stats +// Aggregates total photos across all events the user is a member of, +// and total bytes for photos saved in their personal vault. +// ============================================================ + +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // 1. Get total photos in all events the user is part of + const { data: userEvents, error: eventsError } = await supabase + .from("event_members") + .select("event_id") + .eq("user_id", user.id); + + if (eventsError) { + return NextResponse.json( + { error: "Failed to fetch user events" }, + { status: 500 } + ); + } + + const eventIds = userEvents.map((em) => em.event_id); + let totalPhotos = 0; + + if (eventIds.length > 0) { + const { count: photosCount, error: photosError } = await supabase + .from("photos") + .select("*", { count: "exact", head: true }) + .in("event_id", eventIds) + .eq("status", "active"); + + if (photosError) { + return NextResponse.json( + { error: "Failed to fetch photo count" }, + { status: 500 } + ); + } + totalPhotos = photosCount ?? 0; + } + + // 2. Get total size of all items in personal vault + const { data: vaultItems, error: vaultError } = await supabase + .from("personal_vault") + .select("photo:photos(file_size)") + .eq("user_id", user.id); + + let totalBytes = 0; + if (vaultError) { + return NextResponse.json( + { error: "Failed to fetch vault items" }, + { status: 500 } + ); + } + totalBytes = vaultItems.reduce( + (acc, item) => acc + (item.photo?.file_size ?? 0), + 0 + ); + + return NextResponse.json({ + totalPhotos, + totalBytes, + }); +} diff --git a/apps/vibely-web/app/api/notifications/[id]/route.ts b/apps/vibely-web/app/api/notifications/[id]/route.ts index 763b7a7..ed798e5 100644 --- a/apps/vibely-web/app/api/notifications/[id]/route.ts +++ b/apps/vibely-web/app/api/notifications/[id]/route.ts @@ -13,7 +13,9 @@ type RouteParams = { params: Promise<{ id: string }> }; export async function PATCH(_req: Request, { params }: RouteParams) { const { id } = await params; const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -27,7 +29,10 @@ export async function PATCH(_req: Request, { params }: RouteParams) { .eq("user_id", user.id); if (updateError) { - return NextResponse.json({ error: "Failed to update notification" }, { status: 500 }); + return NextResponse.json( + { error: "Failed to update notification" }, + { status: 500 } + ); } return NextResponse.json({ success: true }); diff --git a/apps/vibely-web/app/api/notifications/route.ts b/apps/vibely-web/app/api/notifications/route.ts index 793e153..ed0a221 100644 --- a/apps/vibely-web/app/api/notifications/route.ts +++ b/apps/vibely-web/app/api/notifications/route.ts @@ -10,18 +10,22 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; -import { z } from "zod"; - -const markReadSchema = z.object({ - all: z.boolean().optional(), - ids: z.array(z.string().uuid()).optional() -}).refine(data => data.all || (data.ids && data.ids.length > 0), { - message: "Must provide either 'all: true' or a non-empty array of 'ids'" -}); +import { unknown, z } from "zod"; + +const markReadSchema = z + .object({ + all: z.boolean().optional(), + ids: z.array(z.string().uuid()).optional(), + }) + .refine((data) => data.all || (data.ids && data.ids.length > 0), { + message: "Must provide either 'all: true' or a non-empty array of 'ids'", + }); export async function GET() { const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -37,7 +41,10 @@ export async function GET() { .limit(20); if (fetchError) { - return NextResponse.json({ error: "Failed to fetch notifications" }, { status: 500 }); + return NextResponse.json( + { error: "Failed to fetch notifications" }, + { status: 500 } + ); } // Get unread count @@ -48,29 +55,44 @@ export async function GET() { .eq("is_read", false); if (countError) { - return NextResponse.json({ error: "Failed to fetch notification count" }, { status: 500 }); + return NextResponse.json( + { error: "Failed to fetch notification count" }, + { status: 500 } + ); } return NextResponse.json({ notifications: notifications || [], - unread_count: count || 0 + unread_count: count || 0, }); } export async function PATCH(req: Request) { const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + let body = unknown; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + try { - const body = await req.json(); const result = markReadSchema.safeParse(body); if (!result.success) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 } + ); } const { all, ids } = result.data; @@ -87,11 +109,17 @@ export async function PATCH(req: Request) { const { error: updateError } = await updateQuery; if (updateError) { - return NextResponse.json({ error: "Failed to update notifications" }, { status: 500 }); + return NextResponse.json( + { error: "Failed to update notifications" }, + { status: 500 } + ); } return NextResponse.json({ success: true }); } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + return NextResponse.json( + { error: "Failed to update notifications" }, + { status: 500 } + ); } } diff --git a/apps/vibely-web/app/dashboard/page.tsx b/apps/vibely-web/app/dashboard/page.tsx deleted file mode 100644 index f072f69..0000000 --- a/apps/vibely-web/app/dashboard/page.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -// ============================================================ -// apps/web/app/dashboard/page.tsx -// ============================================================ -// Main dashboard: shows all events the user belongs to, -// separated into "upcoming" and "past" sections. -// Has a prominent "Create event" CTA. -// -// WHY 'use client' here? -// The useEvents hook uses useState/useEffect — Client Component -// territory. For a larger app, you'd fetch the initial events -// list in a Server Component and pass it as props to avoid the -// loading flicker. We keep it simple here for the MVP. -// ============================================================ - -import { useState } from "react"; -import Link from "next/link"; -import { useEvents } from "@/hooks/useEvents"; -import { EventCard } from "@/components/events/EventCard"; -import { isEventExpired } from "@shared/utils/invite"; - -export default function DashboardPage() { - const { events, isLoading, error, deleteEvent } = useEvents(); - const [deletingId, setDeletingId] = useState(null); - - const handleDelete = async (id: string) => { - const event = events.find((e) => e.id === id); - if (!event) return; - - // Simple browser confirm — Phase 14 (polish) will replace with a modal - const confirmed = window.confirm( - `Delete "${event.title}"? This will permanently delete all photos. This cannot be undone.` - ); - if (!confirmed) return; - - setDeletingId(id); - await deleteEvent(id); - setDeletingId(null); - }; - - // Split events into upcoming (active) and past (expired/archived) - const upcoming = events.filter( - (e) => !isEventExpired(e.expires_at) && e.status === "active" - ); - const past = events.filter( - (e) => isEventExpired(e.expires_at) || e.status !== "active" - ); - - return ( -
-
- {/* Page header */} -
-
-

Your Events

-

- {events.length === 0 - ? "No events yet" - : `${events.length} event${events.length !== 1 ? "s" : ""}`} -

-
- - - - - - Create event - -
- - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Loading skeleton */} - {isLoading && ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
- ))} -
- )} - - {/* Empty state */} - {!isLoading && events.length === 0 && ( -
-
📷
-

- No events yet -

-

- Create your first event and start collecting photos from everyone - there. -

- - Create your first event - -
- )} - - {/* Upcoming events */} - {!isLoading && upcoming.length > 0 && ( -
-

- Upcoming -

-
- {upcoming.map((event) => ( -
- -
- ))} -
-
- )} - - {/* Past events */} - {!isLoading && past.length > 0 && ( -
-

- Past events -

-
- {past.map((event) => ( - - ))} -
-
- )} -
-
- ); -} diff --git a/apps/vibely-web/app/events/[id]/page.tsx b/apps/vibely-web/app/events/[id]/page.tsx deleted file mode 100644 index b157967..0000000 --- a/apps/vibely-web/app/events/[id]/page.tsx +++ /dev/null @@ -1,254 +0,0 @@ -"use client"; - -// ============================================================ -// apps/web/app/events/[id]/page.tsx (updated for Phase 9) -// ============================================================ - -import { use } from "react"; -import Link from "next/link"; -import Image from "next/image"; -import { useEvent } from "@/hooks/useEvents"; -import { usePhotos } from "@/hooks/usePhotos"; -import { QRCodeDisplay } from "@/components/events/QRCodeDisplay"; -import { PhotoUploader } from "@/components/photos/PhotoUploader"; -import { PhotoGallery } from "@/components/photos/PhotoGallery"; -import { - formatEventDate, - relativeTime, - isEventExpired, -} from "@shared/utils/invite"; - -type PageProps = { params: Promise<{ id: string }> }; - -export default function EventDetailPage({ params }: PageProps) { - const { id } = use(params); - const { - event, - userRole, - isLoading: eventLoading, - error: eventError, - } = useEvent(id); - const { - photos, - pagination, - isLoading: photosLoading, - uploads, - fetchPage, - uploadFiles, - deletePhoto, - savePhoto, - unsavePhoto, - } = usePhotos(id); - - if (eventLoading) { - return ( -
-
-
- ); - } - - if (eventError || !event) { - return ( -
-
-

- {eventError ?? "Event not found"} -

- - Back to dashboard - -
-
- ); - } - - const isHost = userRole === "host"; - const expired = isEventExpired(event.expires_at); - const canUpload = !expired && event.status === "active"; - - return ( -
- {/* Cover banner */} -
- {event.cover_image_url && ( - {event.title} - )} - - - - - - {isHost && ( -
- - Edit - -
- )} -
- -
- {/* Event meta card */} -
-
-
-

- {event.title} -

-

- {formatEventDate(event.event_date)} -

-
- - {expired ? "Ended" : "Active"} - -
- - {event.description && ( -

- {event.description} -

- )} - -
- - Hosted by{" "} - {event.host?.name} - - · - {event.event_members?.length ?? 0} members - · - {photos.length} photos - · - Expires {relativeTime(event.expires_at)} -
-
- - {/* Side-by-side: invite QR + members */} - {!expired && ( -
-
-

- Invite guests -

- -
- -
-

- Members ({event.event_members?.length ?? 0}) -

-
- {event.event_members?.map((member) => ( -
-
- {member.user?.avatar_url ? ( - - ) : ( - - {(member.user?.name ?? "?")[0].toUpperCase()} - - )} -
-
-

- {member.user?.name ?? "Unknown"} -

-

- {member.role} -

-
-
- ))} -
-
-
- )} - - {/* Photo upload + gallery */} -
-
-

- Photos{" "} - {photos.length > 0 && ( - - ({pagination?.total ?? photos.length}) - - )} -

-
- - {canUpload && ( -
- -
- )} - - {expired && ( -
- This event has ended. Photos are view-only. Save any photos to - your vault before they expire. -
- )} - - { - await deletePhoto(id); - }} - /> -
-
-
- ); -} diff --git a/apps/vibely-web/app/globals.css b/apps/vibely-web/app/globals.css index a92002c..94f47c9 100644 --- a/apps/vibely-web/app/globals.css +++ b/apps/vibely-web/app/globals.css @@ -1,110 +1,116 @@ -@import "tailwindcss"; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --primary: 199 89% 48%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 199 89% 48%; - --radius: 0.5rem; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --primary: 199 89% 48%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 199 89% 48%; - } - - * { - border-color: hsl(var(--border)); - } - - body { - background-color: hsl(var(--background)); - color: hsl(var(--foreground)); - } -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } - - /* Neumorphic Utilities */ - .bg-background-light { - @apply bg-[#E8EDF2]; - } - - .bg-background-dark { - @apply bg-[#171121]; - } - - .shadow-neo-flat { - box-shadow: 8px 8px 16px #c5cad0, -8px -8px 16px #ffffff; - } - - .shadow-neo-inset { - box-shadow: inset 4px 4px 8px #c5cad0, inset -4px -4px 8px #ffffff; - } - - .shadow-neo-button { - box-shadow: 4px 4px 10px rgba(124, 59, 237, 0.3), -2px -2px 6px rgba(255, 255, 255, 0.2); - } - - .neo-card { - box-shadow: 20px 20px 60px #c5cad0, -20px -20px 60px #ffffff; - } - - .bg-neumorphic { - @apply bg-[#e6e9ef]; - /* Maintains compatibility with landing page */ - } - - .shadow-neumorphic { - box-shadow: 12px 12px 24px #c4c6cb, -12px -12px 24px #ffffff; - } - - .shadow-neumorphic-sm { - box-shadow: 6px 6px 12px #c4c6cb, -6px -6px 12px #ffffff; - } - - .shadow-neumorphic-inner { - box-shadow: inset 6px 6px 12px #c4c6cb, inset -6px -6px 12px #ffffff; - } - - /* Specific purple accent buttons using neumorphism */ - .bg-neumorphic-purple { - @apply bg-violet-600; - } - - .shadow-neumorphic-purple { - box-shadow: 6px 6px 12px #6b21a8, -6px -6px 12px #a855f7; - } -} \ No newline at end of file +@import "tailwindcss"; + +@theme { + --color-inverse-surface: #fbf8ff; + --color-on-tertiary: #6a0a31; + --color-on-background: #f8f5fd; + --color-primary-dim: #8a4cfc; + --color-secondary-container: #612b8f; + --color-on-primary-fixed: #000000; + --color-surface-variant: #25252c; + --color-primary-fixed: #b28cff; + --color-error-dim: #d73357; + --color-surface-dim: #0e0e13; + --color-outline: #76747b; + --color-surface: #0e0e13; + --color-tertiary-container: #fe81a4; + --color-on-tertiary-container: #5a0027; + --color-error-container: #a70138; + --color-on-secondary-container: #e5c4ff; + --color-surface-bright: #2b2b33; + --color-primary: #bd9dff; + --color-tertiary-fixed: #ff8eac; + --color-on-surface-variant: #acaab1; + --color-secondary-dim: #be86ef; + --color-surface-container-lowest: #000000; + --color-on-primary-container: #2e006c; + --color-on-tertiary-fixed-variant: #711036; + --color-primary-fixed-dim: #a67aff; + --color-on-secondary: #3b0065; + --color-on-surface: #f8f5fd; + --color-background: #0e0e13; + --color-tertiary: #ff97b2; + --color-secondary-fixed: #e6c5ff; + --color-inverse-primary: #742fe5; + --color-on-secondary-fixed-variant: #6b369a; + --color-secondary: #c38bf5; + --color-error: #ff6e84; + --color-surface-container: #19191f; + --color-inverse-on-surface: #55545a; + --color-secondary-fixed-dim: #ddb3ff; + --color-surface-container-highest: #25252c; + --color-surface-tint: #bd9dff; + --color-on-secondary-fixed: #4d137b; + --color-on-primary-fixed-variant: #390083; + --color-on-error-container: #ffb2b9; + --color-on-error: #490013; + --color-primary-container: #b28cff; + --color-surface-container-low: #131318; + --color-surface-container-high: #1f1f26; + --color-on-tertiary-fixed: #380016; + --color-outline-variant: #48474d; + --color-tertiary-fixed-dim: #f77c9e; + --color-on-primary: #3c0089; + --color-tertiary-dim: #f17799; + + --font-headline: var(--font-jakarta), sans-serif; + --font-body: var(--font-inter), sans-serif; + --font-label: var(--font-inter), sans-serif; + + --radius-xl: 0.75rem; + --radius-2xl: 1.5rem; +} + +@layer base { + body { + background-color: #0e0e13; + color: #f8f5fd; + font-family: var(--font-jakarta), var(--font-inter), sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + ::selection { + background-color: rgba(189, 157, 255, 0.3); + } +} + +@layer utilities { + .glass-card { + background: rgba(25, 25, 31, 0.6); + backdrop-filter: blur(20px); + border: 1px solid rgba(189, 157, 255, 0.05); + } + .hero-gradient { + background: radial-gradient(circle at 50% -20%, rgba(189, 157, 255, 0.15) 0%, rgba(14, 14, 19, 0) 60%); + } + + /* Some of the legacy neumorphic classes kept for compatibility elsewhere */ + .bg-neumorphic { + background-color: var(--color-surface-container); + } + + /* Soft Neumorphic utilities for Event Details page */ + .soft-neumorph-inset { + background: #16161D; + box-shadow: inset 4px 4px 8px #0a0a0d, + inset -4px -4px 8px #22222d; + } + .soft-neumorph-outset { + background: #16161D; + box-shadow: 6px 6px 12px #0a0a0d, + -6px -6px 12px #22222d; + } +} + +/* Base masonry support outside layer for higher reliability */ +.masonry-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-auto-rows: 20px; + gap: 20px; +} + +.masonry-item-short { grid-row-end: span 14; min-height: 280px; } +.masonry-item-tall { grid-row-end: span 22; min-height: 440px; } +.masonry-item-med { grid-row-end: span 18; min-height: 360px; } \ No newline at end of file diff --git a/apps/vibely-web/app/layout.tsx b/apps/vibely-web/app/layout.tsx index 00484fe..5c45847 100644 --- a/apps/vibely-web/app/layout.tsx +++ b/apps/vibely-web/app/layout.tsx @@ -3,18 +3,22 @@ // ============================================================ import type { Metadata } from "next"; -import { Inter } from "next/font/google"; +import { Inter, Plus_Jakarta_Sans } from "next/font/google"; import "./globals.css"; -import { NavBar } from "@/components/layout/NavBar"; import { ToastProvider } from "@/components/layout/Toast"; import { AuthProvider } from "@/context/AuthContext"; -const inter = Inter({ subsets: ["latin"] }); +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); +const plusJakartaSans = Plus_Jakarta_Sans({ + subsets: ["latin"], + variable: "--font-jakarta", + weight: ["400", "500", "600", "700", "800"], +}); export const metadata: Metadata = { - title: "Vibely — Share event photos instantly", + title: "Vibely — Your Memories, Shared Beautifully.", description: - "Create events, share a QR code, and collect photos from everyone — no app required for guests.", + "Vibely is the digital keepsake for your modern events. High-fidelity guest uploads without the friction of app downloads or account creation.", openGraph: { title: "Vibely", description: "Share event photos instantly — no app required for guests.", @@ -28,15 +32,28 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + + + {/* eslint-disable-next-line @next/next/no-page-custom-font */} + +