Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Supabase Configuration
# Get these from your Supabase project: https://supabase.com/dashboard
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key

# Existing webhook (if used)
NEXT_PUBLIC_N8N_WEBHOOK_URL=your_n8n_webhook_url
43 changes: 43 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Dependencies
node_modules/
.pnp/
.pnp.js

# Build
.next/
out/
build/
dist/

# TypeScript
*.tsbuildinfo
next-env.d.ts

# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Vercel
.vercel

# IDE
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Testing
coverage/
.nyc_output/
96 changes: 96 additions & 0 deletions app/arena/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import type { Database } from '@/lib/supabase/types'

export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/arena'
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')

// Handle OAuth errors
if (error) {
console.error('OAuth error:', error, errorDescription)
const errorUrl = new URL('/arena/login', origin)
errorUrl.searchParams.set('error', error)
return NextResponse.redirect(errorUrl)
}

// No code means something went wrong
if (!code) {
console.error('No code in callback')
return NextResponse.redirect(`${origin}/arena/login?error=no_code`)
}

// Create response first so we can attach cookies to it
const redirectUrl = `${origin}${next}`
const response = NextResponse.redirect(redirectUrl)

const supabase = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
// Let Supabase handle cookie options - don't override
response.cookies.set(name, value, options)
})
},
},
}
)

try {
const { data, error: sessionError } = await supabase.auth.exchangeCodeForSession(code)

if (sessionError) {
console.error('Session exchange error:', sessionError)
return NextResponse.redirect(`${origin}/arena/login?error=session_error`)
}

// If we have a user, ensure their profile exists
if (data?.user) {
const { data: existingProfile } = await (supabase.from('profiles') as any)
.select('id')
.eq('id', data.user.id)
.single()

// Create profile if it doesn't exist
if (!existingProfile) {
const metadata = data.user.user_metadata || {}
const newProfile = {
id: data.user.id,
email: data.user.email || '',
username: metadata.preferred_username || metadata.user_name ||
data.user.email?.split('@')[0] || `user_${data.user.id.slice(0, 8)}`,
full_name: metadata.full_name || metadata.name || null,
avatar_url: metadata.avatar_url || metadata.picture || null,
bio: null,
website: null,
skills: [],
role: 'builder',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}

const { error: profileError } = await (supabase.from('profiles') as any)
.upsert(newProfile, { onConflict: 'id' })

if (profileError) {
console.error('Profile creation error:', profileError)
// Don't fail the login if profile creation fails
}
}
}

return response
} catch (err) {
console.error('Auth callback exception:', err)
return NextResponse.redirect(`${origin}/arena/login?error=callback_error`)
}
}
230 changes: 230 additions & 0 deletions app/arena/bookmarks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"use client"

import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { createClient } from "@/lib/supabase/client"
import { useAuth } from "@/lib/supabase/auth-context"
import { ArenaHeader } from "@/components/arena/arena-header"
import { PostCard, PostCardSkeleton } from "@/components/arena/post-card"
import { motion } from "framer-motion"
import { Bookmark, ArrowLeft, Loader2 } from "lucide-react"
import type { PostWithAuthor } from "@/lib/supabase/types"

const POSTS_PER_PAGE = 10

export default function BookmarksPage() {
const router = useRouter()
const { user, loading: authLoading } = useAuth()
const supabase = createClient()

const [bookmarks, setBookmarks] = useState<PostWithAuthor[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)

useEffect(() => {
if (!authLoading && !user) {
router.push("/arena/login")
}
}, [user, authLoading, router])

useEffect(() => {
if (!user) return

async function fetchBookmarks() {
setLoading(true)

const from = 0
const to = POSTS_PER_PAGE - 1

const { data, count, error } = await (supabase.from("bookmarks") as any)
.select(`
id,
created_at,
post:posts!bookmarks_post_id_fkey(
*,
author:profiles!posts_author_id_fkey(*)
)
`, { count: 'exact' })
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.range(from, to)

if (!error && data) {
const posts = data
.map((b: any) => b.post)
.filter((p: any) => p !== null) as PostWithAuthor[]
setBookmarks(posts)
setTotal(count || 0)
setHasMore((count || 0) > POSTS_PER_PAGE)
}

setLoading(false)
}

fetchBookmarks()
}, [user, supabase])

const loadMore = async () => {
if (!user) return
setLoadingMore(true)
const nextPage = page + 1
const from = (nextPage - 1) * POSTS_PER_PAGE
const to = from + POSTS_PER_PAGE - 1

const { data } = await (supabase.from("bookmarks") as any)
.select(`
id,
created_at,
post:posts!bookmarks_post_id_fkey(
*,
author:profiles!posts_author_id_fkey(*)
)
`)
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.range(from, to)

if (data) {
const posts = data
.map((b: any) => b.post)
.filter((p: any) => p !== null) as PostWithAuthor[]
setBookmarks(prev => [...prev, ...posts])
setPage(nextPage)
setHasMore(total > nextPage * POSTS_PER_PAGE)
}

setLoadingMore(false)
}

const handleRemoveBookmark = async (postId: string) => {
if (!user) return
await (supabase.from("bookmarks") as any)
.delete()
.eq("user_id", user.id)
.eq("post_id", postId)

setBookmarks(prev => prev.filter(p => p.id !== postId))
setTotal(prev => prev - 1)
}

if (authLoading) {
return (
<div className="min-h-screen bg-[var(--arena-bg)]">
<ArenaHeader />
<main className="max-w-[800px] mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 bg-[var(--arena-border)] rounded" />
<div className="h-4 w-64 bg-[var(--arena-border)] rounded" />
</div>
</main>
</div>
)
}

return (
<div className="min-h-screen bg-[var(--arena-bg)]">
<ArenaHeader />

<main className="max-w-[800px] mx-auto px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Link
href="/arena"
className="inline-flex items-center gap-2 text-[var(--arena-text-faint)] hover:text-[var(--arena-text)] transition-colors mb-6 text-sm"
>
<ArrowLeft className="w-4 h-4" />
Back
</Link>

<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-[var(--arena-text)]/10 flex items-center justify-center">
<Bookmark className="w-5 h-5 text-[var(--arena-text)]" />
</div>
<h1 className="font-instrument-serif text-3xl text-[var(--arena-text)]">Bookmarks</h1>
</div>
<p className="text-sm text-[var(--arena-text-faint)] ml-[52px]">
{total > 0 ? `${total} saved ${total === 1 ? 'post' : 'posts'}` : 'Save posts to read later'}
</p>
</div>

{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
) : bookmarks.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-[var(--arena-text)]/5 flex items-center justify-center">
<Bookmark className="w-8 h-8 text-[var(--arena-text-faint)]" />
</div>
<h3 className="text-lg font-medium text-[var(--arena-text)] mb-2">No bookmarks yet</h3>
<p className="text-sm text-[var(--arena-text-faint)] mb-6 max-w-xs mx-auto">
When you bookmark posts, they'll appear here for easy access.
</p>
<Link
href="/arena"
className="inline-flex items-center gap-2 bg-[var(--arena-text)] text-[var(--arena-bg)] px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-colors"
>
Explore The Arena
</Link>
</div>
) : (
<>
<div className="space-y-4">
{bookmarks.map((post, index) => (
<motion.div
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(index * 0.05, 0.3) }}
className="relative group"
>
<PostCard post={post} />
<button
onClick={(e) => {
e.preventDefault()
handleRemoveBookmark(post.id)
}}
className="absolute top-4 right-4 p-2 bg-[var(--arena-card)]/90 backdrop-blur rounded-lg text-[var(--arena-text)] opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-[var(--arena-card)]"
title="Remove bookmark"
>
<Bookmark className="w-4 h-4 fill-current" />
</button>
</motion.div>
))}
</div>

{hasMore && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 text-sm text-[var(--arena-text-muted)] hover:text-[var(--arena-text)] transition-colors disabled:opacity-50"
>
{loadingMore ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
'Load more'
)}
</button>
</div>
)}
</>
)}
</motion.div>
</main>
</div>
)
}
Loading