From 4b63a59783c2a96030db522dbd9fee5b7a3e02d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 15:27:16 +0000 Subject: [PATCH 01/17] feat: add The Arena community chat feature Implements a full-featured community platform with: - Supabase integration for database and auth - Magic link and OAuth (Google, GitHub) authentication - Rich text editor with Tiptap for creating posts - Post categories: Building, Ideas, Stories, Opportunities, Challenges - Threaded comments on posts - User profiles with bio, skills, and role - Settings page for profile management - Responsive design maintaining Tenacity aesthetic New pages: - /arena - Main community feed - /arena/login - Authentication page - /arena/new - Create new post - /arena/post/[slug] - Single post view - /arena/profile/[username] - User profile - /arena/settings - Profile settings Database schema included in supabase/migrations/ https://claude.ai/code/session_01Ga6n9rgaGbtbcu7bq5imyi --- .env.local.example | 7 + app/arena/auth/callback/route.ts | 20 + app/arena/layout.tsx | 18 + app/arena/login/page.tsx | 176 ++++ app/arena/new/page.tsx | 280 ++++++ app/arena/page.tsx | 184 ++++ app/arena/post/[slug]/page.tsx | 431 +++++++++ app/arena/profile/[username]/page.tsx | 355 ++++++++ app/arena/settings/page.tsx | 332 +++++++ app/globals.css | 106 +++ components/arena/arena-header.tsx | 199 +++++ components/arena/post-card.tsx | 158 ++++ components/arena/post-content.tsx | 174 ++++ components/arena/rich-text-editor.tsx | 309 +++++++ components/header.tsx | 1 + lib/supabase/auth-context.tsx | 96 ++ lib/supabase/client.ts | 9 + lib/supabase/middleware.ts | 36 + lib/supabase/server.ts | 29 + lib/supabase/types.ts | 227 +++++ middleware.ts | 19 + package-lock.json | 1038 +++++++++++++++++++++- package.json | 9 + supabase/migrations/001_arena_schema.sql | 232 +++++ 24 files changed, 4440 insertions(+), 5 deletions(-) create mode 100644 .env.local.example create mode 100644 app/arena/auth/callback/route.ts create mode 100644 app/arena/layout.tsx create mode 100644 app/arena/login/page.tsx create mode 100644 app/arena/new/page.tsx create mode 100644 app/arena/page.tsx create mode 100644 app/arena/post/[slug]/page.tsx create mode 100644 app/arena/profile/[username]/page.tsx create mode 100644 app/arena/settings/page.tsx create mode 100644 components/arena/arena-header.tsx create mode 100644 components/arena/post-card.tsx create mode 100644 components/arena/post-content.tsx create mode 100644 components/arena/rich-text-editor.tsx create mode 100644 lib/supabase/auth-context.tsx create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/middleware.ts create mode 100644 lib/supabase/server.ts create mode 100644 lib/supabase/types.ts create mode 100644 middleware.ts create mode 100644 supabase/migrations/001_arena_schema.sql 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/app/arena/auth/callback/route.ts b/app/arena/auth/callback/route.ts new file mode 100644 index 0000000..0a03536 --- /dev/null +++ b/app/arena/auth/callback/route.ts @@ -0,0 +1,20 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + const next = searchParams.get('next') ?? '/arena' + + if (code) { + const supabase = await createClient() + const { error } = await supabase.auth.exchangeCodeForSession(code) + + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + // Return the user to an error page with instructions + return NextResponse.redirect(`${origin}/arena/login?error=auth_failed`) +} diff --git a/app/arena/layout.tsx b/app/arena/layout.tsx new file mode 100644 index 0000000..8d18fd5 --- /dev/null +++ b/app/arena/layout.tsx @@ -0,0 +1,18 @@ +import { AuthProvider } from "@/lib/supabase/auth-context" + +export const metadata = { + title: "The Arena | Tenacity", + description: "A community of builders, thinkers, and doers. Share ideas, discuss ventures, and connect.", +} + +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..d82218f --- /dev/null +++ b/app/arena/login/page.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useState } from "react" +import { createClient } from "@/lib/supabase/client" +import { motion, AnimatePresence } from "framer-motion" +import Link from "next/link" +import { useRouter } from "next/navigation" + +export default function LoginPage() { + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) + const router = useRouter() + 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') => { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/arena/auth/callback`, + }, + }) + + if (error) { + setMessage({ type: 'error', text: error.message }) + } + } + + 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)} + className="w-full border-2 border-[#E0DEDB] rounded-lg px-4 py-3 focus:outline-none focus:border-[#37322f] text-[#37322f] transition-colors" + 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..ef8c9ae --- /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. Please try again.") + 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-[#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/page.tsx b/app/arena/page.tsx new file mode 100644 index 0000000..932b104 --- /dev/null +++ b/app/arena/page.tsx @@ -0,0 +1,184 @@ +"use client" + +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { createClient } from "@/lib/supabase/client" +import { ArenaHeader } from "@/components/arena/arena-header" +import { PostCard, PostCardSkeleton, EmptyPostState } from "@/components/arena/post-card" +import { motion } from "framer-motion" +import type { PostWithAuthor, PostCategory } from "@/lib/supabase/types" +import Link from "next/link" + +const categories = [ + { slug: "all", label: "All Posts", icon: "โœจ" }, + { slug: "building", label: "Building", icon: "๐Ÿ› ๏ธ" }, + { slug: "ideas", label: "Ideas", icon: "๐Ÿ’ก" }, + { slug: "stories", label: "Stories", icon: "๐Ÿ“–" }, + { slug: "opportunities", label: "Opportunities", icon: "๐Ÿค" }, + { slug: "challenges", label: "Challenges", icon: "๐ŸŽฏ" }, +] + +export default function ArenaPage() { + const searchParams = useSearchParams() + const categoryParam = searchParams.get("category") + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [activeCategory, setActiveCategory] = useState(categoryParam || "all") + const supabase = createClient() + + useEffect(() => { + setActiveCategory(categoryParam || "all") + }, [categoryParam]) + + useEffect(() => { + async function fetchPosts() { + setLoading(true) + + let query = (supabase + .from("posts") as any) + .select(` + *, + author:profiles!posts_author_id_fkey(*) + `) + .eq("published", true) + .order("created_at", { ascending: false }) + + if (activeCategory && activeCategory !== "all") { + query = query.eq("category", activeCategory) + } + + const { data, error } = await query + + if (error) { + console.error("Error fetching posts:", error) + } else { + setPosts(data as PostWithAuthor[] || []) + } + + setLoading(false) + } + + fetchPosts() + }, [activeCategory]) + + return ( +
+ + +
+ {/* Hero Section */} + +

+ The Arena +

+

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

+
+ + {/* Category Filter (Desktop Sidebar Style) */} +
+ {/* Sidebar */} + +
+

+ Categories +

+ + + {/* Decorative Element */} +
+

+ Think. Build. Do. Grow. +

+

+ Share your journey with fellow builders and doers. +

+
+
+
+ + {/* Main Content */} + + {/* Posts Grid */} + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post, index) => ( + + + + ))} +
+ )} +
+
+
+ + {/* Footer */} +
+
+
+

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

+
+ + Home + + + Manifesto + + + The Arena + +
+
+
+
+
+ ) +} diff --git a/app/arena/post/[slug]/page.tsx b/app/arena/post/[slug]/page.tsx new file mode 100644 index 0000000..733356f --- /dev/null +++ b/app/arena/post/[slug]/page.tsx @@ -0,0 +1,431 @@ +"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 { PostContent } from "@/components/arena/post-content" +import { motion, AnimatePresence } from "framer-motion" +import { formatDistanceToNow } from "date-fns" +import { + ArrowLeft, + MessageCircle, + Eye, + Share2, + Bookmark, + MoreHorizontal, + Send, + Flame, + Lightbulb, + Rocket, + Dumbbell, + Heart, +} from "lucide-react" +import type { PostWithAuthor, CommentWithAuthor, PostCategory, ReactionType } from "@/lib/supabase/types" + +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" }, + lightbulb: { icon: , label: "Idea" }, + launch: { icon: , label: "Launch" }, + tenacity: { icon: , label: "Tenacity" }, + respect: { icon: , label: "Respect" }, +} + +export default function PostPage() { + const params = useParams() + const slug = params.slug as string + const { user, profile } = useAuth() + const supabase = createClient() + + const [post, setPost] = useState(null) + const [comments, setComments] = useState([]) + const [loading, setLoading] = useState(true) + const [newComment, setNewComment] = useState("") + const [submittingComment, setSubmittingComment] = useState(false) + const [showReactions, setShowReactions] = useState(false) + + 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) + + // Increment view count + await (supabase + .from("posts") as any) + .update({ view_count: (postData.view_count || 0) + 1 }) + .eq("id", postData.id) + + // Fetch comments + const { data: commentsData } = await (supabase + .from("comments") as any) + .select(` + *, + author:profiles!comments_author_id_fkey(*) + `) + .eq("post_id", postData.id) + .order("created_at", { ascending: true }) + + setComments((commentsData as CommentWithAuthor[]) || []) + setLoading(false) + } + + fetchPost() + }, [slug]) + + 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([...comments, data as CommentWithAuthor]) + setNewComment("") + } + + setSubmittingComment(false) + } + + const handleShare = async () => { + if (navigator.share) { + await navigator.share({ + title: post?.title, + url: window.location.href, + }) + } else { + await navigator.clipboard.writeText(window.location.href) + // Could add a toast notification here + } + } + + 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 Button */} + + + Back to The Arena + + + {/* Post Header */} +
+ {/* Category */} + + {category.icon} {post.category} + + + {/* Title */} +

+ {post.title} +

+ + {/* Author Info */} +
+ +
+ {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} + +

+ @{post.author.username} ยท {timeAgo} +

+
+
+
+ + {/* Tags */} + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* Post Content */} +
+ +
+ + {/* Actions Bar */} +
+
+ {/* Reactions */} +
+ + + {showReactions && ( + + {Object.entries(reactionConfig).map(([type, config]) => ( + + ))} + + )} + +
+ + {/* Comments count */} + + + {comments.length} + + + {/* Views */} + + + {post.view_count} + +
+ +
+ + +
+
+ + {/* Comments Section */} +
+

+ Discussion ({comments.length}) +

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