diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..d0dce77 --- /dev/null +++ b/.env.local.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3f8782 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/app/arena/auth/callback/route.ts b/app/arena/auth/callback/route.ts new file mode 100644 index 0000000..63dd7e7 --- /dev/null +++ b/app/arena/auth/callback/route.ts @@ -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( + 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`) + } +} diff --git a/app/arena/bookmarks/page.tsx b/app/arena/bookmarks/page.tsx new file mode 100644 index 0000000..b0f2220 --- /dev/null +++ b/app/arena/bookmarks/page.tsx @@ -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([]) + 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 ( +
+ +
+
+
+
+
+
+
+ ) + } + + return ( +
+ + +
+ + + + Back + + +
+
+
+ +
+

Bookmarks

+
+

+ {total > 0 ? `${total} saved ${total === 1 ? 'post' : 'posts'}` : 'Save posts to read later'} +

+
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : bookmarks.length === 0 ? ( +
+
+ +
+

No bookmarks yet

+

+ When you bookmark posts, they'll appear here for easy access. +

+ + Explore The Arena + +
+ ) : ( + <> +
+ {bookmarks.map((post, index) => ( + + + + + ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} +
+
+
+ ) +} diff --git a/app/arena/edit/[slug]/page.tsx b/app/arena/edit/[slug]/page.tsx new file mode 100644 index 0000000..e7e6dd3 --- /dev/null +++ b/app/arena/edit/[slug]/page.tsx @@ -0,0 +1,317 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter, useParams } from "next/navigation" +import Link from "next/link" +import { createClient } from "@/lib/supabase/client" +import { useAuth } from "@/lib/supabase/auth-context" +import { RichTextEditor } from "@/components/arena/rich-text-editor" +import { motion } from "framer-motion" +import { ArrowLeft, X, Loader2 } from "lucide-react" +import type { PostCategory, Post } from "@/lib/supabase/types" + +const categories: { value: PostCategory; label: string; icon: string; description: string }[] = [ + { value: "building", label: "Building", icon: "🛠️", description: "Projects, code, products" }, + { value: "ideas", label: "Ideas", icon: "💡", description: "Concepts, pitches, feedback requests" }, + { value: "stories", label: "Stories", icon: "📖", description: "Journeys, lessons, experiences" }, + { value: "opportunities", label: "Opportunities", icon: "🤝", description: "Jobs, collabs, partnerships" }, + { value: "challenges", label: "Challenges", icon: "🎯", description: "Hackathons, community challenges" }, +] + +function extractExcerpt(content: string): string { + try { + const parsed = JSON.parse(content) + const textContent = parsed.content + ?.map((node: { content?: { text?: string }[] }) => + node.content?.map((c: { text?: string }) => c.text || "").join("") + ) + .join(" ") + .trim() + + return textContent?.substring(0, 200) || "" + } catch { + return "" + } +} + +export default function EditPostPage() { + const router = useRouter() + const params = useParams() + const slug = params.slug as string + const { user, loading: authLoading } = useAuth() + const supabase = createClient() + + const [post, setPost] = useState(null) + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const [category, setCategory] = useState("") + const [tags, setTags] = useState([]) + const [tagInput, setTagInput] = useState("") + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState("") + + useEffect(() => { + async function fetchPost() { + const { data, error: fetchError } = await (supabase + .from("posts") as any) + .select("*") + .eq("slug", slug) + .single() + + if (fetchError || !data) { + console.error("Error fetching post:", fetchError) + router.push("/arena") + return + } + + // Check if user is the author + if (user && data.author_id !== user.id) { + router.push(`/arena/post/${slug}`) + return + } + + setPost(data as Post) + setTitle(data.title) + setContent(JSON.stringify(data.content)) + setCategory(data.category) + setTags(data.tags || []) + setLoading(false) + } + + if (!authLoading) { + if (!user) { + router.push("/arena/login") + } else { + fetchPost() + } + } + }, [slug, user, authLoading, router, supabase]) + + const handleAddTag = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + const tag = tagInput.trim().toLowerCase().replace(/[^a-z0-9-]/g, "") + if (tag && !tags.includes(tag) && tags.length < 5) { + setTags([...tags, tag]) + setTagInput("") + } + } + } + + const handleRemoveTag = (tagToRemove: string) => { + setTags(tags.filter((t) => t !== tagToRemove)) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (!title.trim()) { + setError("Please add a title") + return + } + if (!category) { + setError("Please select a category") + return + } + if (!content || content === "{}") { + setError("Please add some content") + return + } + if (!post) { + setError("Post not found") + return + } + + setSubmitting(true) + + const excerpt = extractExcerpt(content) + + const { error: updateError } = await (supabase + .from("posts") as any) + .update({ + title: title.trim(), + content: JSON.parse(content), + excerpt, + category: category as PostCategory, + tags: tags.length > 0 ? tags : null, + updated_at: new Date().toISOString(), + }) + .eq("id", post.id) + + if (updateError) { + console.error("Error updating post:", updateError) + setError(`Failed to update post: ${updateError.message}`) + setSubmitting(false) + return + } + + router.push(`/arena/post/${post.slug}`) + } + + if (authLoading || loading) { + return ( +
+
+ +

Loading post...

+
+
+ ) + } + + if (!user || !post) { + return null + } + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Main Content */} +
+ +
+

Edit Post

+

Make changes to your post below.

+
+ +
+ {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Title */} +
+ setTitle(e.target.value)} + placeholder="Your post title..." + className="w-full text-3xl sm:text-4xl font-instrument-serif text-[#37322f] bg-transparent border-none focus:outline-none placeholder:text-[#605A57]/50" + maxLength={150} + /> +

+ {title.length}/150 characters +

+
+ + {/* Category Selection */} +
+ +
+ {categories.map((cat) => ( + + ))} +
+
+ + {/* Tags */} +
+ +
+ {tags.map((tag) => ( + + #{tag} + + + ))} +
+ setTagInput(e.target.value)} + onKeyDown={handleAddTag} + placeholder="Add tags (press Enter)" + disabled={tags.length >= 5} + className="w-full border-2 border-[#E0DEDB] rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-[#37322f] text-[#37322f] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+ + {/* Rich Text Editor */} +
+ + +
+ + {/* Submit Button (Mobile) */} +
+ +
+
+
+
+
+ ) +} diff --git a/app/arena/layout.tsx b/app/arena/layout.tsx new file mode 100644 index 0000000..971c31a --- /dev/null +++ b/app/arena/layout.tsx @@ -0,0 +1,49 @@ +import { AuthProvider } from "@/lib/supabase/auth-context" +import { ThemeProvider } from "@/lib/theme-context" +import type { Metadata } from "next" + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://10na.city' + +export const metadata: Metadata = { + title: "The Arena | Tenacity", + description: "Where builders share ideas, discuss ventures, and forge connections. Step in, speak up, and grow together.", + openGraph: { + title: "The Arena", + description: "Where builders share ideas, discuss ventures, and forge connections. Step in, speak up, and grow together.", + url: `${SITE_URL}/arena`, + siteName: "Tenacity", + images: [ + { + url: `${SITE_URL}/arena-og.png`, + width: 1200, + height: 630, + alt: "The Arena - A community for builders", + }, + ], + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "The Arena | Tenacity", + description: "Where builders share ideas, discuss ventures, and forge connections.", + images: [`${SITE_URL}/arena-og.png`], + }, + alternates: { + canonical: `${SITE_URL}/arena`, + }, +} + +export default function ArenaLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} diff --git a/app/arena/login/page.tsx b/app/arena/login/page.tsx new file mode 100644 index 0000000..5303ad2 --- /dev/null +++ b/app/arena/login/page.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useState } from "react" +import { createClient } from "@/lib/supabase/client" +import { motion, AnimatePresence } from "framer-motion" +import Link from "next/link" +import { Loader2 } from "lucide-react" + +export default function LoginPage() { + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) + const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) + const supabase = createClient() + + const handleMagicLink = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setMessage(null) + + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${window.location.origin}/arena/auth/callback`, + }, + }) + + if (error) { + setMessage({ type: 'error', text: error.message }) + } else { + setMessage({ type: 'success', text: 'Check your email for the magic link!' }) + } + setLoading(false) + } + + const handleOAuthLogin = async (provider: 'google' | 'github') => { + setOauthLoading(provider) + setMessage(null) + + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/arena/auth/callback`, + }, + }) + + if (error) { + setMessage({ type: 'error', text: error.message }) + setOauthLoading(null) + } + // Don't reset loading here - we're redirecting to OAuth provider + } + + const isDisabled = loading || oauthLoading !== null + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Main Content */} +
+ + {/* Decorative lines */} +
+
+
+ +
+
+

+ Enter The Arena +

+

+ Join the community of builders, thinkers, and doers. +

+
+ + {/* OAuth Buttons */} +
+ + + +
+ + {/* Divider */} +
+
+
+
+
+ or continue with email +
+
+ + {/* Magic Link Form */} +
+ setEmail(e.target.value)} + disabled={isDisabled} + className="w-full border-2 border-[var(--arena-border)] bg-[var(--arena-bg)] rounded-lg px-4 py-3 focus:outline-none focus:border-[var(--arena-text)] text-[var(--arena-text)] placeholder:text-[var(--arena-text-faint)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + required + /> + +
+ + {/* Message */} + + {message && ( + + {message.text} + + )} + + + {/* Footer */} +

+ By continuing, you agree to join a community that values +
+ courage, creativity, and execution. +

+
+
+ +
+
+ ) +} diff --git a/app/arena/new/page.tsx b/app/arena/new/page.tsx new file mode 100644 index 0000000..9b612cd --- /dev/null +++ b/app/arena/new/page.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useState, useEffect } 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 { RichTextEditor } from "@/components/arena/rich-text-editor" +import { motion } from "framer-motion" +import { ArrowLeft, X } from "lucide-react" +import type { PostCategory } from "@/lib/supabase/types" + +const categories: { value: PostCategory; label: string; icon: string; description: string }[] = [ + { value: "building", label: "Building", icon: "🛠️", description: "Projects, code, products" }, + { value: "ideas", label: "Ideas", icon: "💡", description: "Concepts, pitches, feedback requests" }, + { value: "stories", label: "Stories", icon: "📖", description: "Journeys, lessons, experiences" }, + { value: "opportunities", label: "Opportunities", icon: "🤝", description: "Jobs, collabs, partnerships" }, + { value: "challenges", label: "Challenges", icon: "🎯", description: "Hackathons, community challenges" }, +] + +function generateSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .substring(0, 80) + "-" + Date.now().toString(36) +} + +function extractExcerpt(content: string): string { + try { + const parsed = JSON.parse(content) + const textContent = parsed.content + ?.map((node: { content?: { text?: string }[] }) => + node.content?.map((c: { text?: string }) => c.text || "").join("") + ) + .join(" ") + .trim() + + return textContent?.substring(0, 200) || "" + } catch { + return "" + } +} + +export default function NewPostPage() { + const router = useRouter() + const { user, profile, loading: authLoading } = useAuth() + const supabase = createClient() + + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const [category, setCategory] = useState("") + const [tags, setTags] = useState([]) + const [tagInput, setTagInput] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState("") + + useEffect(() => { + if (!authLoading && !user) { + router.push("/arena/login") + } + }, [user, authLoading, router]) + + const handleAddTag = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + const tag = tagInput.trim().toLowerCase().replace(/[^a-z0-9-]/g, "") + if (tag && !tags.includes(tag) && tags.length < 5) { + setTags([...tags, tag]) + setTagInput("") + } + } + } + + const handleRemoveTag = (tagToRemove: string) => { + setTags(tags.filter((t) => t !== tagToRemove)) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (!title.trim()) { + setError("Please add a title") + return + } + if (!category) { + setError("Please select a category") + return + } + if (!content || content === "{}") { + setError("Please add some content") + return + } + + setSubmitting(true) + + const slug = generateSlug(title) + const excerpt = extractExcerpt(content) + + const { data, error: insertError } = await (supabase + .from("posts") as any) + .insert({ + author_id: user!.id, + title: title.trim(), + slug, + content: JSON.parse(content), + excerpt, + category: category as PostCategory, + tags: tags.length > 0 ? tags : null, + published: true, + }) + .select() + .single() + + if (insertError) { + console.error("Error creating post:", insertError) + setError(`Failed to create post: ${insertError.message}`) + setSubmitting(false) + return + } + + router.push(`/arena/post/${slug}`) + } + + if (authLoading) { + return ( +
+
+
+ ) + } + + if (!user) { + return null + } + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Main Content */} +
+ +
+ {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Title */} +
+ setTitle(e.target.value)} + placeholder="Your post title..." + className="w-full text-3xl sm:text-4xl font-instrument-serif text-[var(--arena-text)] bg-transparent border-none focus:outline-none placeholder:text-[var(--arena-text-muted)]/50" + maxLength={150} + /> +

+ {title.length}/150 characters +

+
+ + {/* Category Selection */} +
+ +
+ {categories.map((cat) => ( + + ))} +
+
+ + {/* Tags */} +
+ +
+ {tags.map((tag) => ( + + #{tag} + + + ))} +
+ setTagInput(e.target.value)} + onKeyDown={handleAddTag} + placeholder="Add tags (press Enter)" + disabled={tags.length >= 5} + className="w-full border-2 border-[var(--arena-border)] bg-[var(--arena-bg)] rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-[var(--arena-text)] text-[var(--arena-text)] placeholder:text-[var(--arena-text-faint)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+ + {/* Rich Text Editor */} +
+ + +
+ + {/* Submit Button (Mobile) */} +
+ +
+
+
+
+
+ ) +} diff --git a/app/arena/notifications/page.tsx b/app/arena/notifications/page.tsx new file mode 100644 index 0000000..a33e9c2 --- /dev/null +++ b/app/arena/notifications/page.tsx @@ -0,0 +1,387 @@ +"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 { motion, AnimatePresence } from "framer-motion" +import { formatDistanceToNow } from "date-fns" +import { + ArrowLeft, + Bell, + Heart, + MessageCircle, + UserPlus, + AtSign, + Check, + Loader2, + Trash2, +} from "lucide-react" + +interface Notification { + id: string + type: "follow" | "reaction" | "comment" | "reply" | "mention" + read: boolean + created_at: string + actor: { + id: string + username: string + full_name: string | null + avatar_url: string | null + } + post?: { + id: string + slug: string + title: string + } + comment?: { + id: string + content: string + } +} + +const notificationIcons = { + follow: UserPlus, + reaction: Heart, + comment: MessageCircle, + reply: MessageCircle, + mention: AtSign, +} + +const notificationMessages = { + follow: "started following you", + reaction: "reacted to your post", + comment: "commented on your post", + reply: "replied to your comment", + mention: "mentioned you in a comment", +} + +export default function NotificationsPage() { + const router = useRouter() + const { user, loading: authLoading } = useAuth() + const supabase = createClient() + + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState<"all" | "unread">("all") + + useEffect(() => { + if (!authLoading && !user) { + router.push("/arena/login") + } + }, [user, authLoading, router]) + + useEffect(() => { + async function fetchNotifications() { + if (!user) return + + setLoading(true) + + let query = (supabase.from("notifications") as any) + .select(` + id, + type, + read, + created_at, + actor:profiles!notifications_actor_id_fkey(id, username, full_name, avatar_url), + post:posts(id, slug, title), + comment:comments(id, content) + `) + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + + if (filter === "unread") { + query = query.eq("read", false) + } + + const { data, error } = await query + + if (!error && data) { + setNotifications(data) + } + + setLoading(false) + } + + fetchNotifications() + }, [user, filter]) + + const markAsRead = async (notificationId: string) => { + await (supabase.from("notifications") as any) + .update({ read: true }) + .eq("id", notificationId) + + setNotifications((prev) => + prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n)) + ) + } + + const markAllAsRead = async () => { + if (!user) return + + await (supabase.from("notifications") as any) + .update({ read: true }) + .eq("user_id", user.id) + .eq("read", false) + + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + } + + const deleteNotification = async (notificationId: string) => { + await (supabase.from("notifications") as any) + .delete() + .eq("id", notificationId) + + setNotifications((prev) => prev.filter((n) => n.id !== notificationId)) + } + + const clearAllNotifications = async () => { + if (!user) return + + await (supabase.from("notifications") as any) + .delete() + .eq("user_id", user.id) + + setNotifications([]) + } + + const unreadCount = notifications.filter((n) => !n.read).length + + if (authLoading) { + return ( +
+ +
+
+
+
+
+
+
+
+
+ ) + } + + return ( +
+ + +
+ + {/* Back Button */} + + + Back to The Arena + + + {/* Header */} +
+
+

+ Notifications +

+ {unreadCount > 0 && ( + + {unreadCount} new + + )} +
+ +
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ + {/* Filter Tabs */} +
+ + +
+ + {/* Notifications List */} + {loading ? ( +
+ +
+ ) : notifications.length === 0 ? ( +
+
+ +
+

+ {filter === "unread" ? "All caught up!" : "No notifications yet"} +

+

+ {filter === "unread" + ? "You've read all your notifications." + : "When someone interacts with your posts or follows you, you'll see it here."} +

+
+ ) : ( +
+ + {notifications.map((notification, index) => { + const Icon = notificationIcons[notification.type] + const message = notificationMessages[notification.type] + + return ( + + {/* Unread indicator */} + {!notification.read && ( +
+ )} + + {/* Actor Avatar */} + + {notification.actor.avatar_url ? ( + {notification.actor.username} + ) : ( + (notification.actor.full_name?.[0] || notification.actor.username[0]).toUpperCase() + )} + + + {/* Content */} +
+ { + if (!notification.read) { + markAsRead(notification.id) + } + }} + className="block" + > +

+ + {notification.actor.full_name || notification.actor.username} + {" "} + {message} +

+ {notification.post && ( +

+ "{notification.post.title}" +

+ )} + {notification.comment && ( +

+ {notification.comment.content} +

+ )} +

+ {formatDistanceToNow(new Date(notification.created_at), { + addSuffix: true, + })} +

+ +
+ + {/* Icon */} +
+ +
+ + {/* Actions */} +
+ {!notification.read && ( + + )} + +
+ + ) + })} + +
+ )} +
+
+
+ ) +} diff --git a/app/arena/page.tsx b/app/arena/page.tsx new file mode 100644 index 0000000..1e3dbb6 --- /dev/null +++ b/app/arena/page.tsx @@ -0,0 +1,86 @@ +import { Suspense } from "react" +import { ArenaHeader } from "@/components/arena/arena-header" +import { ArenaContent } from "@/components/arena/arena-content" +import { PostCardSkeleton } from "@/components/arena/post-card" +import Link from "next/link" + +function ArenaContentFallback() { + return ( + <> + {/* Hero Section Skeleton */} +
+

+ The Arena +

+

+ Where builders share ideas, discuss ventures, and forge connections. + Step in, speak up, and grow together. +

+
+ +
+ {/* Sidebar Skeleton */} + + + {/* Main Content Skeleton */} +
+
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+ + ) +} + +export default function ArenaPage() { + return ( +
+ + +
+ }> + + +
+ + {/* Footer */} +
+
+
+

+ © {new Date().getFullYear()} Tenacity. All rights reserved. +

+
+ + Home + + + Manifesto + + + The Arena + +
+
+
+
+
+ ) +} diff --git a/app/arena/post/[slug]/layout.tsx b/app/arena/post/[slug]/layout.tsx new file mode 100644 index 0000000..a181c88 --- /dev/null +++ b/app/arena/post/[slug]/layout.tsx @@ -0,0 +1,72 @@ +import { createClient } from "@/lib/supabase/server" +import type { Metadata } from "next" + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://10na.city' + +interface Props { + params: Promise<{ slug: string }> + children: React.ReactNode +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + const supabase = await createClient() + + const { data: post } = await (supabase + .from("posts") as any) + .select(` + *, + author:profiles!posts_author_id_fkey(full_name, username) + `) + .eq("slug", slug) + .single() + + if (!post) { + return { + title: "Post Not Found | The Arena", + description: "This post doesn't exist or has been removed.", + } + } + + const authorName = post.author?.full_name || post.author?.username || 'Anonymous' + const description = post.excerpt || `A post by ${authorName} in The Arena` + + return { + title: `${post.title} | The Arena`, + description, + authors: [{ name: authorName }], + openGraph: { + title: post.title, + description, + url: `${SITE_URL}/arena/post/${slug}`, + siteName: "Tenacity", + images: [ + { + url: `${SITE_URL}/arena-og.png`, + width: 1200, + height: 630, + alt: post.title, + }, + ], + locale: "en_US", + type: "article", + publishedTime: post.created_at, + modifiedTime: post.updated_at, + authors: [authorName], + tags: post.tags || [], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description, + images: [`${SITE_URL}/arena-og.png`], + }, + alternates: { + canonical: `${SITE_URL}/arena/post/${slug}`, + }, + } +} + +export default function PostLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/app/arena/post/[slug]/page.tsx b/app/arena/post/[slug]/page.tsx new file mode 100644 index 0000000..c573360 --- /dev/null +++ b/app/arena/post/[slug]/page.tsx @@ -0,0 +1,1109 @@ +"use client" + +import { useEffect, useState, useCallback, useRef } from "react" +import { useParams, 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 { PostContent } from "@/components/arena/post-content" +import { motion, AnimatePresence } from "framer-motion" +import { formatDistanceToNow } from "date-fns" +import { + ArrowLeft, + MessageCircle, + Eye, + Share2, + Bookmark, + BookmarkCheck, + MoreHorizontal, + Send, + Flame, + Lightbulb, + Rocket, + Dumbbell, + Heart, + Trash2, + Pencil, + Loader2, + Reply, + ChevronDown, + ChevronUp, + Check, + CornerDownRight, + ArrowUp, + Clock, + Twitter, + Linkedin, + Link as LinkIcon, +} from "lucide-react" +import type { PostWithAuthor, CommentWithAuthor, PostCategory, ReactionType, Reaction, CommentWithReplies } from "@/lib/supabase/types" +import { MentionInput, extractMentions, renderContentWithMentions } from "@/components/arena/mention-input" + +const COMMENTS_PER_PAGE = 5 + +// Calculate reading time based on word count +function calculateReadingTime(content: any): number { + if (!content) return 1 + try { + const text = JSON.stringify(content) + const words = text.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean).length + const wordsPerMinute = 200 + return Math.max(1, Math.ceil(words / wordsPerMinute)) + } catch { + return 1 + } +} + +const categoryStyles: Record = { + building: { bg: "bg-amber-50", text: "text-amber-700", icon: "🛠️" }, + ideas: { bg: "bg-yellow-50", text: "text-yellow-700", icon: "💡" }, + stories: { bg: "bg-blue-50", text: "text-blue-700", icon: "📖" }, + opportunities: { bg: "bg-green-50", text: "text-green-700", icon: "🤝" }, + challenges: { bg: "bg-purple-50", text: "text-purple-700", icon: "🎯" }, +} + +const reactionConfig: Record = { + fire: { icon: , label: "Fire", color: "text-orange-500" }, + lightbulb: { icon: , label: "Idea", color: "text-yellow-500" }, + launch: { icon: , label: "Launch", color: "text-blue-500" }, + tenacity: { icon: , label: "Tenacity", color: "text-purple-500" }, + respect: { icon: , label: "Respect", color: "text-red-500" }, +} + +// Build comment tree from flat list +function buildCommentTree(comments: CommentWithAuthor[]): CommentWithReplies[] { + const commentMap = new Map() + const roots: CommentWithReplies[] = [] + + comments.forEach(comment => { + commentMap.set(comment.id, { ...comment, replies: [] }) + }) + + comments.forEach(comment => { + const node = commentMap.get(comment.id)! + if (comment.parent_id) { + const parent = commentMap.get(comment.parent_id) + if (parent) { + parent.replies = parent.replies || [] + parent.replies.push(node) + } else { + roots.push(node) + } + } else { + roots.push(node) + } + }) + + return roots +} + +// Minimal, slick comment component +function CommentItem({ + comment, + postId, + user, + profile, + supabase, + onDelete, + onUpdate, + onReply, + depth = 0, +}: { + comment: CommentWithReplies + postId: string + user: any + profile: any + supabase: any + onDelete: (id: string) => void + onUpdate: (id: string, content: string) => void + onReply: (parentId: string, content: string) => Promise + depth?: number +}) { + const [showActions, setShowActions] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [editContent, setEditContent] = useState(comment.content) + const [isReplying, setIsReplying] = useState(false) + const [replyContent, setReplyContent] = useState("") + const [submitting, setSubmitting] = useState(false) + const [showReplies, setShowReplies] = useState(depth < 2) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const isAuthor = user?.id === comment.author_id + const hasReplies = comment.replies && comment.replies.length > 0 + const maxDepth = 3 + + const handleEdit = async () => { + if (!editContent.trim()) return + setSubmitting(true) + await onUpdate(comment.id, editContent.trim()) + setIsEditing(false) + setSubmitting(false) + } + + const handleReply = async () => { + if (!replyContent.trim()) return + setSubmitting(true) + await onReply(comment.id, replyContent.trim()) + setReplyContent("") + setIsReplying(false) + setSubmitting(false) + setShowReplies(true) + } + + return ( +
0 ? 'ml-8 pl-4 border-l border-[#E0DEDB]/60' : ''}`}> + setShowActions(true)} + onMouseLeave={() => !isEditing && setShowActions(false)} + > + {/* Comment header */} +
+ +
+ {comment.author.avatar_url ? ( + + ) : ( + (comment.author.full_name?.[0] || comment.author.username[0]).toUpperCase() + )} +
+ + + {comment.author.full_name || comment.author.username} + + · + + {formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })} + + {comment.updated_at !== comment.created_at && ( + (edited) + )} +
+ + {/* Comment content */} + {isEditing ? ( +
+ +
+ + +
+
+ ) : ( +

+ {renderContentWithMentions(comment.content)} +

+ )} + + {/* Actions - appear on hover */} + {!isEditing && ( +
+ {user && depth < maxDepth && ( + + )} + {isAuthor && ( + <> + + + + )} +
+ )} + + {/* Reply count toggle */} + {hasReplies && ( + + )} + + {/* Reply form */} + + {isReplying && ( + +
+ +
+ +
+ + +
+
+
+
+ )} +
+ + {/* Nested replies */} + + {showReplies && hasReplies && ( + + {comment.replies?.map(reply => ( + + ))} + + )} + +
+ + {/* Delete confirmation - minimal modal */} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + > + e.stopPropagation()} + className="bg-white rounded-xl p-5 max-w-xs w-full shadow-2xl" + > +

Delete this comment?

+
+ + +
+
+
+ )} +
+
+ ) +} + +export default function PostPage() { + const params = useParams() + const router = useRouter() + const slug = params.slug as string + const { user, profile } = useAuth() + const supabase = createClient() + + const [post, setPost] = useState(null) + const [comments, setComments] = useState([]) + const [reactions, setReactions] = useState([]) + const [userReaction, setUserReaction] = useState(null) + const [isBookmarked, setIsBookmarked] = useState(false) + const [loading, setLoading] = useState(true) + const [newComment, setNewComment] = useState("") + const [submittingComment, setSubmittingComment] = useState(false) + const [showReactions, setShowReactions] = useState(false) + const [showPostMenu, setShowPostMenu] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [deleting, setDeleting] = useState(false) + const [copied, setCopied] = useState(false) + const [commentsPage, setCommentsPage] = useState(1) + const [hasMoreComments, setHasMoreComments] = useState(false) + const [loadingMoreComments, setLoadingMoreComments] = useState(false) + const [totalComments, setTotalComments] = useState(0) + const [rootCommentCount, setRootCommentCount] = useState(0) + const [showShareMenu, setShowShareMenu] = useState(false) + const [showBackToTop, setShowBackToTop] = useState(false) + + const postMenuRef = useRef(null) + const reactionsRef = useRef(null) + const shareMenuRef = useRef(null) + + const isAuthor = user?.id === post?.author_id + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (postMenuRef.current && !postMenuRef.current.contains(event.target as Node)) { + setShowPostMenu(false) + } + if (reactionsRef.current && !reactionsRef.current.contains(event.target as Node)) { + setShowReactions(false) + } + if (shareMenuRef.current && !shareMenuRef.current.contains(event.target as Node)) { + setShowShareMenu(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Show back to top button on scroll + useEffect(() => { + const handleScroll = () => { + setShowBackToTop(window.scrollY > 400) + } + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + const fetchReactions = useCallback(async (postId: string) => { + const { data } = await (supabase.from("reactions") as any).select("*").eq("post_id", postId) + if (data) { + setReactions(data as Reaction[]) + if (user) { + const myReaction = data.find((r: Reaction) => r.user_id === user.id) + setUserReaction(myReaction?.type || null) + } + } + }, [supabase, user]) + + const fetchBookmark = useCallback(async (postId: string) => { + if (!user) return + const { data } = await (supabase.from("bookmarks") as any) + .select("id") + .eq("user_id", user.id) + .eq("post_id", postId) + .single() + setIsBookmarked(!!data) + }, [supabase, user]) + + const fetchComments = useCallback(async (postId: string, page: number, append = false) => { + if (append) setLoadingMoreComments(true) + + // Fetch root comments with pagination + const from = (page - 1) * COMMENTS_PER_PAGE + const to = from + COMMENTS_PER_PAGE - 1 + + // First get total count of root comments + const { count: rootCount } = await (supabase.from("comments") as any) + .select("id", { count: 'exact', head: true }) + .eq("post_id", postId) + .is("parent_id", null) + + setRootCommentCount(rootCount || 0) + + // Get paginated root comments + const { data: rootComments } = await (supabase.from("comments") as any) + .select(`*, author:profiles!comments_author_id_fkey(*)`) + .eq("post_id", postId) + .is("parent_id", null) + .order("created_at", { ascending: false }) + .range(from, to) + + if (rootComments && rootComments.length > 0) { + // Get all replies for these root comments + const rootIds = rootComments.map((c: any) => c.id) + const { data: replies } = await (supabase.from("comments") as any) + .select(`*, author:profiles!comments_author_id_fkey(*)`) + .eq("post_id", postId) + .in("parent_id", rootIds) + .order("created_at", { ascending: true }) + + const allComments = [...rootComments, ...(replies || [])] + + if (append) { + setComments(prev => { + const existingIds = new Set(prev.map(c => c.id)) + const newComments = allComments.filter((c: any) => !existingIds.has(c.id)) + return [...prev, ...newComments] + }) + } else { + setComments(allComments as CommentWithAuthor[]) + } + } else if (!append) { + setComments([]) + } + + // Get total comment count + const { count: total } = await (supabase.from("comments") as any) + .select("id", { count: 'exact', head: true }) + .eq("post_id", postId) + + setTotalComments(total || 0) + setHasMoreComments((rootCount || 0) > page * COMMENTS_PER_PAGE) + setLoadingMoreComments(false) + }, [supabase]) + + useEffect(() => { + async function fetchPost() { + const { data: postData, error: postError } = await (supabase.from("posts") as any) + .select(`*, author:profiles!posts_author_id_fkey(*)`) + .eq("slug", slug) + .single() + + if (postError) { + console.error("Error fetching post:", postError) + setLoading(false) + return + } + + setPost(postData as PostWithAuthor) + + await (supabase.from("posts") as any) + .update({ view_count: (postData.view_count || 0) + 1 }) + .eq("id", postData.id) + + await Promise.all([ + fetchComments(postData.id, 1), + fetchReactions(postData.id), + fetchBookmark(postData.id), + ]) + + setLoading(false) + } + + fetchPost() + }, [slug, fetchComments, fetchReactions, fetchBookmark, supabase]) + + useEffect(() => { + if (user && reactions.length > 0) { + const myReaction = reactions.find(r => r.user_id === user.id) + setUserReaction(myReaction?.type || null) + } + }, [user, reactions]) + + const loadMoreComments = () => { + const nextPage = commentsPage + 1 + setCommentsPage(nextPage) + if (post) fetchComments(post.id, nextPage, true) + } + + const handleReaction = async (type: ReactionType) => { + if (!user || !post) return + setShowReactions(false) + + if (userReaction === type) { + setUserReaction(null) + setReactions(reactions.filter(r => r.user_id !== user.id)) + await (supabase.from("reactions") as any).delete().eq("user_id", user.id).eq("post_id", post.id) + } else { + const isNewReaction = !userReaction + setUserReaction(type) + if (userReaction) { + setReactions(reactions.map(r => r.user_id === user.id ? { ...r, type } : r)) + await (supabase.from("reactions") as any).update({ type }).eq("user_id", user.id).eq("post_id", post.id) + } else { + const newReaction = { user_id: user.id, post_id: post.id, type } + setReactions([...reactions, newReaction as Reaction]) + await (supabase.from("reactions") as any).insert(newReaction) + } + + // Create notification for post author (only for new reactions, not type changes) + if (isNewReaction && post.author_id !== user.id) { + await (supabase.from("notifications") as any).insert({ + user_id: post.author_id, + actor_id: user.id, + type: "reaction", + post_id: post.id, + }) + } + } + } + + const handleBookmark = async () => { + if (!user || !post) { + router.push('/arena/login') + return + } + + const wasBookmarked = isBookmarked + setIsBookmarked(!isBookmarked) + + if (wasBookmarked) { + await (supabase.from("bookmarks") as any).delete().eq("user_id", user.id).eq("post_id", post.id) + } else { + await (supabase.from("bookmarks") as any).insert({ user_id: user.id, post_id: post.id }) + } + } + + const handleSubmitComment = async (e: React.FormEvent) => { + e.preventDefault() + if (!user || !post || !newComment.trim()) return + + setSubmittingComment(true) + const { data, error } = await (supabase.from("comments") as any) + .insert({ post_id: post.id, author_id: user.id, content: newComment.trim() }) + .select(`*, author:profiles!comments_author_id_fkey(*)`) + .single() + + if (!error && data) { + setComments(prev => [data as CommentWithAuthor, ...prev]) + setNewComment("") + setTotalComments(prev => prev + 1) + setRootCommentCount(prev => prev + 1) + + // Create notification for post author (if not commenting on own post) + if (post.author_id !== user.id) { + await (supabase.from("notifications") as any).insert({ + user_id: post.author_id, + actor_id: user.id, + type: "comment", + post_id: post.id, + comment_id: data.id, + }) + } + + // Create notifications for mentioned users + const mentions = extractMentions(newComment.trim()) + if (mentions.length > 0) { + // Fetch mentioned users' IDs + const { data: mentionedUsers } = await (supabase.from("profiles") as any) + .select("id, username") + .in("username", mentions) + + if (mentionedUsers) { + const notifications = mentionedUsers + .filter((u: any) => u.id !== user.id && u.id !== post.author_id) // Don't notify self or post author (already notified) + .map((u: any) => ({ + user_id: u.id, + actor_id: user.id, + type: "mention", + post_id: post.id, + comment_id: data.id, + })) + + if (notifications.length > 0) { + await (supabase.from("notifications") as any).insert(notifications) + } + } + } + } + setSubmittingComment(false) + } + + const handleReplyToComment = async (parentId: string, content: string) => { + if (!user || !post) return + const { data, error } = await (supabase.from("comments") as any) + .insert({ post_id: post.id, author_id: user.id, parent_id: parentId, content }) + .select(`*, author:profiles!comments_author_id_fkey(*)`) + .single() + + if (!error && data) { + setComments(prev => [...prev, data as CommentWithAuthor]) + setTotalComments(prev => prev + 1) + + // Find the parent comment to notify its author + const parentComment = comments.find(c => c.id === parentId) + if (parentComment && parentComment.author_id !== user.id) { + await (supabase.from("notifications") as any).insert({ + user_id: parentComment.author_id, + actor_id: user.id, + type: "reply", + post_id: post.id, + comment_id: data.id, + }) + } + + // Create notifications for mentioned users + const mentions = extractMentions(content) + if (mentions.length > 0) { + const { data: mentionedUsers } = await (supabase.from("profiles") as any) + .select("id, username") + .in("username", mentions) + + if (mentionedUsers) { + const alreadyNotified = [user.id] + if (parentComment) alreadyNotified.push(parentComment.author_id) + + const notifications = mentionedUsers + .filter((u: any) => !alreadyNotified.includes(u.id)) + .map((u: any) => ({ + user_id: u.id, + actor_id: user.id, + type: "mention", + post_id: post.id, + comment_id: data.id, + })) + + if (notifications.length > 0) { + await (supabase.from("notifications") as any).insert(notifications) + } + } + } + } + } + + const handleDeleteComment = async (commentId: string) => { + const repliesToDelete = comments.filter(c => c.parent_id === commentId) + const isRoot = !comments.find(c => c.id === commentId)?.parent_id + + for (const reply of repliesToDelete) { + await (supabase.from("comments") as any).delete().eq("id", reply.id) + } + await (supabase.from("comments") as any).delete().eq("id", commentId) + + setComments(comments.filter(c => c.id !== commentId && c.parent_id !== commentId)) + setTotalComments(prev => prev - 1 - repliesToDelete.length) + if (isRoot) setRootCommentCount(prev => prev - 1) + } + + const handleUpdateComment = async (commentId: string, content: string) => { + await (supabase.from("comments") as any) + .update({ content, updated_at: new Date().toISOString() }) + .eq("id", commentId) + setComments(comments.map(c => c.id === commentId ? { ...c, content, updated_at: new Date().toISOString() } : c)) + } + + const handleDeletePost = async () => { + if (!user || !post || !isAuthor) return + setDeleting(true) + await (supabase.from("reactions") as any).delete().eq("post_id", post.id) + await (supabase.from("comments") as any).delete().eq("post_id", post.id) + await (supabase.from("bookmarks") as any).delete().eq("post_id", post.id) + const { error } = await (supabase.from("posts") as any).delete().eq("id", post.id) + if (error) { + setDeleting(false) + setShowDeleteConfirm(false) + } else { + router.push("/arena") + } + } + + const handleShare = async () => { + if (navigator.share) { + await navigator.share({ title: post?.title, url: window.location.href }) + } else { + setShowShareMenu(!showShareMenu) + } + } + + const handleCopyLink = async () => { + await navigator.clipboard.writeText(window.location.href) + setCopied(true) + setShowShareMenu(false) + setTimeout(() => setCopied(false), 2000) + } + + const handleShareTwitter = () => { + const text = encodeURIComponent(`${post?.title} by @${post?.author.username}`) + const url = encodeURIComponent(window.location.href) + window.open(`https://twitter.com/intent/tweet?text=${text}&url=${url}`, '_blank', 'width=550,height=420') + setShowShareMenu(false) + } + + const handleShareLinkedIn = () => { + const url = encodeURIComponent(window.location.href) + window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=550,height=420') + setShowShareMenu(false) + } + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + const reactionCounts = reactions.reduce((acc, r) => { + acc[r.type] = (acc[r.type] || 0) + 1 + return acc + }, {} as Record) + + const totalReactions = reactions.length + const commentTree = buildCommentTree(comments) + + if (loading) { + return ( +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + if (!post) { + return ( +
+ +
+

Post Not Found

+

This post doesn't exist or has been removed.

+ + Back to The Arena + +
+
+ ) + } + + const category = categoryStyles[post.category] + const timeAgo = formatDistanceToNow(new Date(post.created_at), { addSuffix: true }) + + return ( +
+ + +
+ + + Back + + +
+
+ + {category.icon} {post.category} + + + {isAuthor && ( +
+ + + {showPostMenu && ( + + setShowPostMenu(false)}> + Edit + + + + )} + +
+ )} +
+ +

{post.title}

+ +
+ +
+ {post.author.avatar_url ? ( + {post.author.full_name + ) : ( + (post.author.full_name?.[0] || post.author.username[0]).toUpperCase() + )} +
+ +
+ + {post.author.full_name || post.author.username} + +
+ {timeAgo} + · + + + {calculateReadingTime(post.content)} min read + +
+
+
+
+ + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map((tag) => ( + #{tag} + ))} +
+ )} + +
+ +
+ + {/* Actions Bar */} +
+
+
+ + + {showReactions && ( + + {(Object.entries(reactionConfig) as [ReactionType, typeof reactionConfig[ReactionType]][]).map(([type, config]) => ( + + ))} + + )} + +
+ + + {totalComments} + + + + {post.view_count} + +
+ +
+ {/* Share Menu */} +
+ + + {showShareMenu && ( + + + + + + )} + +
+ +
+
+ + {/* Comments Section */} +
+

Discussion

+ + {user ? ( +
+
+
+ {profile?.avatar_url ? ( + + ) : ( + (profile?.full_name?.[0] || profile?.username?.[0] || "U").toUpperCase() + )} +
+
+ +
+ +
+
+
+
+ ) : ( +
+

Join the conversation

+ + Enter The Arena + +
+ )} + + {commentTree.length === 0 ? ( +
+ +

No comments yet. Start the discussion!

+
+ ) : ( +
+ {commentTree.map((comment) => ( + + ))} + + {/* Load more comments */} + {hasMoreComments && ( +
+ +
+ )} +
+ )} +
+
+
+ + {/* Delete Post Confirmation */} + + {showDeleteConfirm && ( + !deleting && setShowDeleteConfirm(false)}> + e.stopPropagation()} className="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl"> +

Delete this post?

+

This will permanently remove "{post.title}" and all its comments.

+
+ + +
+
+
+ )} +
+ + {/* Back to Top Button */} + + {showBackToTop && ( + + + + )} + +
+ ) +} diff --git a/app/arena/profile/[username]/page.tsx b/app/arena/profile/[username]/page.tsx new file mode 100644 index 0000000..69c61f9 --- /dev/null +++ b/app/arena/profile/[username]/page.tsx @@ -0,0 +1,477 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } 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 { formatDistanceToNow } from "date-fns" +import { + ArrowLeft, + Calendar, + Link as LinkIcon, + MapPin, + Settings, + Users, + UserPlus, + UserMinus, + Loader2, +} from "lucide-react" +import type { Profile, PostWithAuthor } from "@/lib/supabase/types" + +const roleLabels = { + builder: { label: "Builder", bg: "bg-amber-100 dark:bg-amber-900/30", text: "text-amber-700 dark:text-amber-300" }, + partner: { label: "Partner", bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-300" }, + learner: { label: "Learner", bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-700 dark:text-green-300" }, +} + +export default function ProfilePage() { + const params = useParams() + const username = params.username as string + const { user, profile: currentUserProfile } = useAuth() + const supabase = createClient() + + const [profile, setProfile] = useState(null) + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<"posts" | "about">("posts") + + // Follow feature state + const [isFollowing, setIsFollowing] = useState(false) + const [followerCount, setFollowerCount] = useState(0) + const [followingCount, setFollowingCount] = useState(0) + const [followLoading, setFollowLoading] = useState(false) + + const isOwnProfile = currentUserProfile?.username === username + + useEffect(() => { + async function fetchProfile() { + // Fetch profile + const { data: profileData, error: profileError } = await (supabase + .from("profiles") as any) + .select("*") + .eq("username", username) + .single() + + if (profileError) { + console.error("Error fetching profile:", profileError) + setLoading(false) + return + } + + setProfile(profileData) + + // Fetch user's posts + const { data: postsData } = await (supabase + .from("posts") as any) + .select(` + *, + author:profiles!posts_author_id_fkey(*) + `) + .eq("author_id", profileData.id) + .eq("published", true) + .order("created_at", { ascending: false }) + + setPosts((postsData as PostWithAuthor[]) || []) + + // Fetch follower and following counts + const [followerResult, followingResult] = await Promise.all([ + (supabase.from("follows") as any) + .select("*", { count: "exact", head: true }) + .eq("following_id", profileData.id), + (supabase.from("follows") as any) + .select("*", { count: "exact", head: true }) + .eq("follower_id", profileData.id), + ]) + + setFollowerCount(followerResult.count || 0) + setFollowingCount(followingResult.count || 0) + + setLoading(false) + } + + fetchProfile() + }, [username]) + + // Check if current user is following this profile + useEffect(() => { + async function checkFollowStatus() { + if (!user || !profile || isOwnProfile) return + + const { data } = await (supabase + .from("follows") as any) + .select("id") + .eq("follower_id", user.id) + .eq("following_id", profile.id) + .single() + + setIsFollowing(!!data) + } + + checkFollowStatus() + }, [user, profile, isOwnProfile]) + + const handleFollow = async () => { + if (!user || !profile || followLoading) return + + setFollowLoading(true) + + try { + if (isFollowing) { + // Unfollow + await (supabase.from("follows") as any) + .delete() + .eq("follower_id", user.id) + .eq("following_id", profile.id) + + setIsFollowing(false) + setFollowerCount((prev) => Math.max(0, prev - 1)) + } else { + // Follow + await (supabase.from("follows") as any).insert({ + follower_id: user.id, + following_id: profile.id, + }) + + setIsFollowing(true) + setFollowerCount((prev) => prev + 1) + + // Create notification for the followed user + await (supabase.from("notifications") as any).insert({ + user_id: profile.id, + actor_id: user.id, + type: "follow", + }) + } + } catch (error) { + console.error("Error toggling follow:", error) + } finally { + setFollowLoading(false) + } + } + + if (loading) { + return ( +
+ +
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ ) + } + + if (!profile) { + return ( +
+ +
+

User Not Found

+

This user doesn't exist or has been removed.

+ + + Back to The Arena + +
+
+ ) + } + + const role = roleLabels[profile.role] + const joinedDate = formatDistanceToNow(new Date(profile.created_at), { addSuffix: true }) + + return ( +
+ + +
+ + {/* Back Button */} + + + Back to The Arena + + + {/* Profile Header */} +
+
+ {/* Avatar */} +
+ {profile.avatar_url ? ( + {profile.full_name + ) : ( + (profile.full_name?.[0] || profile.username[0]).toUpperCase() + )} +
+ + {/* Info */} +
+
+

+ {profile.full_name || profile.username} +

+ + {role.label} + +
+ +

@{profile.username}

+ + {profile.bio && ( +

{profile.bio}

+ )} + + {/* Meta Info */} +
+ + + Joined {joinedDate} + + {profile.website && ( + + + Website + + )} +
+ + {/* Skills */} + {profile.skills && profile.skills.length > 0 && ( +
+ {profile.skills.map((skill) => ( + + {skill} + + ))} +
+ )} +
+ + {/* Actions */} +
+ {isOwnProfile ? ( + + + Edit Profile + + ) : user ? ( + + ) : ( + + + Follow + + )} +
+
+
+ + {/* Stats */} +
+
+

{posts.length}

+

Posts

+
+
+

{followerCount}

+

Followers

+
+
+

{followingCount}

+

Following

+
+
+

+ {posts.reduce((acc, post) => acc + post.view_count, 0)} +

+

Views

+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Tab Content */} + {activeTab === "posts" ? ( + posts.length === 0 ? ( +
+
+ ✍️ +
+

+ {isOwnProfile ? "Share your first post" : "No posts yet"} +

+

+ {isOwnProfile + ? "Start sharing your thoughts, ideas, and ventures with the community." + : "This user hasn't published any posts yet."} +

+ {isOwnProfile && ( + + Write Your First Post + + )} +
+ ) : ( +
+ {posts.map((post, index) => ( + + + + ))} +
+ ) + ) : ( +
+

+ About {profile.full_name || profile.username} +

+ {profile.bio ? ( +

{profile.bio}

+ ) : ( +

No bio provided yet.

+ )} + + {profile.skills && profile.skills.length > 0 && ( +
+

Skills & Interests

+
+ {profile.skills.map((skill) => ( + + {skill} + + ))} +
+
+ )} + + {profile.website && ( + + )} +
+ )} +
+
+
+ ) +} diff --git a/app/arena/settings/page.tsx b/app/arena/settings/page.tsx new file mode 100644 index 0000000..06308f1 --- /dev/null +++ b/app/arena/settings/page.tsx @@ -0,0 +1,332 @@ +"use client" + +import { useState, useEffect } 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 { motion } from "framer-motion" +import { ArrowLeft, X, Save, Check } from "lucide-react" + +export default function SettingsPage() { + const router = useRouter() + const { user, profile, loading: authLoading, refreshProfile } = useAuth() + const supabase = createClient() + + const [fullName, setFullName] = useState("") + const [username, setUsername] = useState("") + const [bio, setBio] = useState("") + const [website, setWebsite] = useState("") + const [skills, setSkills] = useState([]) + const [skillInput, setSkillInput] = useState("") + const [role, setRole] = useState<"builder" | "partner" | "learner">("builder") + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState("") + + useEffect(() => { + if (!authLoading && !user) { + router.push("/arena/login") + } + }, [user, authLoading, router]) + + useEffect(() => { + if (profile) { + setFullName(profile.full_name || "") + setUsername(profile.username || "") + setBio(profile.bio || "") + setWebsite(profile.website || "") + setSkills(profile.skills || []) + setRole(profile.role) + } + }, [profile]) + + const handleAddSkill = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + const skill = skillInput.trim() + if (skill && !skills.includes(skill) && skills.length < 10) { + setSkills([...skills, skill]) + setSkillInput("") + } + } + } + + const handleRemoveSkill = (skillToRemove: string) => { + setSkills(skills.filter((s) => s !== skillToRemove)) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setSaved(false) + + if (!username.trim()) { + setError("Username is required") + return + } + + // Validate username format + if (!/^[a-z0-9_-]+$/i.test(username)) { + setError("Username can only contain letters, numbers, underscores, and hyphens") + return + } + + setSaving(true) + + const { error: updateError } = await (supabase + .from("profiles") as any) + .update({ + full_name: fullName.trim() || null, + username: username.trim().toLowerCase(), + bio: bio.trim() || null, + website: website.trim() || null, + skills: skills.length > 0 ? skills : null, + role, + }) + .eq("id", user!.id) + + if (updateError) { + if (updateError.code === "23505") { + setError("This username is already taken") + } else { + setError("Failed to update profile. Please try again.") + } + setSaving(false) + return + } + + await refreshProfile() + setSaving(false) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } + + if (authLoading) { + return ( +
+
+
+ ) + } + + if (!user) { + return null + } + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Main Content */} +
+ +

Settings

+

Manage your profile and preferences

+ +
+ {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Avatar Section */} +
+

Profile Picture

+
+
+ {profile?.avatar_url ? ( + {profile.full_name + ) : ( + (fullName?.[0] || username?.[0] || "U").toUpperCase() + )} +
+

+ Profile picture is automatically set from your social login provider. +

+
+
+ + {/* Basic Info */} +
+

Basic Information

+ +
+ + setFullName(e.target.value)} + placeholder="Your full name" + className="w-full border-2 border-[var(--arena-border)] bg-[var(--arena-bg)] rounded-lg px-4 py-2.5 text-[var(--arena-text)] placeholder:text-[var(--arena-text-faint)] focus:outline-none focus:border-[var(--arena-text)] transition-colors" + /> +
+ +
+ +
+ @ + setUsername(e.target.value.toLowerCase())} + placeholder="username" + className="flex-1 border-2 border-[var(--arena-border)] bg-[var(--arena-bg)] rounded-lg px-4 py-2.5 text-[var(--arena-text)] placeholder:text-[var(--arena-text-faint)] focus:outline-none focus:border-[var(--arena-text)] transition-colors" + required + /> +
+
+ +
+ +