# Project export: ConsentfulCivicLens

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 12.0
- Tagline: CCL is an AI coordinator for live events that suggests next actions, auto-generates recap content, and protects privacy with on-the-fly face blurring, keeping operations smooth and shareable.
- Devpost: https://devpost.com/software/consentfulciviclens
- GitHub: https://github.com/shaaronl/ConsentfulCivicLens
- Video: https://player.vimeo.com/video/1130678609?byline=0&portrait=0&title=0#t=
- Team: 4 GitHub contributor(s) — Anupriya (10 commits), himankgalundia (10 commits), Sharon Liang (2 commits), Qianwen Gao (2 commits)

## Devpost submission (written by the team)

### Inspiration

Civic engagement events — whether community cleanups, donation drives, or hackathons — often struggle with two problems: coordination chaos and privacy concerns. Volunteers and organizers juggle logistics, media collection, and real-time updates, while trying to ensure that no participant’s identity is exposed without consent. We wanted to build a platform that combines AI coordination, ethical privacy, and content generation — giving organizers a smart way to manage operations while protecting participants’ identities. That’s how ConsentfulCivicLens was born: a system that helps event organizers coordinate smoothly, automatically blur faces in photos and videos, and generate meaningful content summaries to share with their communities.

### What it does

ConsentfulCivicLens is an AI-powered event coordination and media management platform designed for privacy-first civic engagement. Smart Event Coordination: Suggests next-step actions for organizers — from setup verification to volunteer deployment — using contextual event data and progress tracking. Smart Event Coordination: Suggests next-step actions for organizers — from setup verification to volunteer deployment — using contextual event data and progress tracking. Privacy-Preserving Media Uploads: Automatically detects and blurs faces in photos and videos to protect participants’ privacy, with customizable settings for consented individuals or minors. Privacy-Preserving Media Uploads: Automatically detects and blurs faces in photos and videos to protect participants’ privacy, with customizable settings for consented individuals or minors. AI-Driven Content Creation: Uses media uploads to auto-generate highlight reels, captions, and social posts, saving organizers hours of post-event work. AI-Driven Content Creation: Uses media uploads to auto-generate highlight reels, captions, and social posts, saving organizers hours of post-event work. Organizer Dashboard: Displays real-time updates, team tasks, and media uploads in a single interface — making event operations seamless and proactive. Organizer Dashboard: Displays real-time updates, team tasks, and media uploads in a single interface — making event operations seamless and proactive.

### How we built it

Frontend: Built using Next.js and Tailwind CSS, creating a clean and responsive interface for event creation, task tracking, and uploads. Backend: Powered by Supabase (PostgreSQL, Auth, Storage) for secure file management, user authentication, and scalable data handling. Face Blurring Pipeline: Implemented in Python using MediaPipe and OpenCV, enabling automatic anonymization of faces in uploaded images and videos. Content Generation: Integrated ffmpeg for trimming and formatting media, and LLMs for auto-generating captions, summaries, and social post drafts. Recommendation Engine: Developed a hybrid rule-based logic that identifies pending tasks and suggests contextually relevant next actions to organizers.

### Challenges we ran into

Balancing Privacy and Usability: Ensuring that the face blurring system was reliable but didn’t distort content quality. Balancing Privacy and Usability: Ensuring that the face blurring system was reliable but didn’t distort content quality. Real-Time Media Processing: Managing large file uploads while maintaining smooth front-end performance. Real-Time Media Processing: Managing large file uploads while maintaining smooth front-end performance. Time Constraints: Integrating computer vision, backend storage, and frontend UI within the hackathon timeline. Time Constraints: Integrating computer vision, backend storage, and frontend UI within the hackathon timeline. System Coordination: Making sure the recommendation logic and upload verification worked together seamlessly. System Coordination: Making sure the recommendation logic and upload verification worked together seamlessly.

### Accomplishments we're proud of

Built a fully functional end-to-end media privacy pipeline that automatically blurs faces. Created an intelligent event dashboard for real-time updates and task suggestions. Designed an AI-powered content creation system that transforms raw uploads into shareable social posts. Implemented a foundation for a privacy-first civic engagement tool that can scale to larger events.

### What we learned

How to integrate computer vision and LLMs into practical, socially impactful workflows. The importance of privacy and consent when dealing with civic or community-based data. How to manage full-stack systems that combine real-time updates, media processing, and AI decision-making. Effective team collaboration and agile problem-solving under time pressure.

### What's next

Our current MVP showcases the potential of AI-assisted, privacy-respecting event management — but the full vision is much larger. We plan to expand ConsentfulCivicLens into a comprehensive civic engagement platform with the following next steps: 1. Full Consent Infrastructure Deploy QR-based consent badges allowing attendees to opt in/out of visibility. Build identity-linked consent databases for recurring volunteers and civic partners. 2. Smart Media Director Add multi-camera support with automatic highlight detection. Use speech-to-text and caption generation for accessible event storytelling. Enable AI video summarization that creates short, shareable reels instantly. 3. Predictive Coordination System Integrate event analytics to forecast bottlenecks like volunteer shortages or check-in queues. Provide predictive, context-aware “next best action” recommendations during live operations. 4. One-Click Publishing & Analytics Connect directly to social media for ethical content sharing with attribution and privacy tagging. Add post-event analytics dashboards tracking engagement, impact, and volunteer efficiency. 5. Civic Partnerships Collaborate with community organizations, universities, and municipalities to pilot ConsentfulCivicLens for real civic use cases — from public cleanups to cultural events.

## README (from the GitHub repository)

# Consentful Civic Lens (CCL)

An event-centric, photo/video forum for your campus and neighborhood. Capture moments with consent, create community stories, and take action together.

## 🌟 Features

### Privacy-First Community Building
- **Automatic Face Blurring**: Non-consenting faces are automatically blurred using on-device face detection
- **Per-Event Consent Tokens**: Privacy tokens are event-specific and never stored permanently
- **Consent Management**: Users can opt-in per event with secure, salted face hashes

### Visual Storytelling
- **Community Highlights**: Auto-generated 60-second recaps from event photos/videos
- **Multi-Perspective Cuts**: Community, Organizer, and Family-friendly versions
- **Source Transparency**: See which media contributed to each story

### Action-Oriented Engagement
- **Smart Recommendations**: AI-powered action suggestions based on user profile and event context
- **Action Cards**: Volunteer shifts, donation requests, forms, and follow-up meetings
- **Impact Tracking**: Measure real community outcomes

### Accessibility & Safety
- **Auto Captions**: Generated for all video content
- **Alt Text**: Automatic descriptions for images
- **Safety Checks**: Flicker, loudness, and content moderation
- **Accessibility Scoring**: Built-in metrics for inclusive content

## 🚀 Quick Start

### Prerequisites
- Node.js 18+ 
- Supabase account
- Stripe account (for payments)

### 1. Clone and Install
```bash
git clone <your-repo-url>
cd ConsentfulCivicLens
npm install
```

### 2. Environment Setup
Create a `.env.local` file with your Supabase credentials:

```env
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key

# Database
POSTGRES_URL=your_postgres_connection_string

# Authentication
AUTH_SECRET=your_auth_secret_key

# Stripe (optional)
STRIPE_SECRET_KEY=your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
```

### 3. Database Setup

#### Option A: Using Supabase (Recommended)
1. Go to your Supabase dashboard
2. Navigate to SQL Editor
3. Run the migration script from `lib/db/migrations/0000_soft_the_anarchist.sql`

#### Option B: Using Drizzle
```bash
npm run db:setup
npm run db:migrate
npm run db:seed
```

### 4. Supabase Configuration

#### Enable Authentication
1. Go to Authentication > Settings in your Supabase dashboard
2. Enable email authentication
3. Configure email templates if desired

#### Set up Row Level Security (RLS)
Run these SQL commands in your Supabase SQL editor:

```sql
-- Enable RLS on all tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE consent_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE action_participants ENABLE ROW LEVEL SECURITY;

-- Users can only see their own data
CREATE POLICY "Users can view own profile" ON users
  FOR SELECT USING (auth.uid()::text = id::text);

CREATE POLICY "Users can update own profile" ON users
  FOR UPDATE USING (auth.uid()::text = id::text);

-- Events are public to read, but only organizers can create/update
CREATE POLICY "Events are viewable by everyone" ON events
  FOR SELECT USING (true);

CREATE POLICY "Only organizers can create events" ON events
  FOR INSERT WITH CHECK (
    EXISTS (
      SELECT 1 FROM users 
      WHERE users.id = organizer_id 
      AND users.role = 'organizer'
      AND auth.uid()::text = users.id::text
    )
  );

-- Assets are viewable by everyone, but only the uploader can create
CREATE POLICY "Assets are viewable by everyone" ON assets
  FOR SELECT USING (true);

CREATE POLICY "Users can upload assets" ON assets
  FOR INSERT WITH CHECK (auth.uid()::text = user_id::text);

-- Consent tokens are private to the user
CREATE POLICY "Users can manage own consent tokens" ON consent_tokens
  FOR ALL USING (auth.uid()::text = user_id::text);

-- Actions are viewable by everyone
CREATE POLICY "Actions are viewable by everyone" ON actions
  FOR SELECT USING (true);

-- Action participants can be managed by the user
CREATE POLICY "Users can manage own action participation" ON action_participants
  FOR ALL USING (auth.uid()::text = user_id::text);
```

#### Set up Storage
1. Go to Storage in your Supabase dashboard
2. Create a new bucket called `media-assets`
3. Set up the following policies:

```sql
-- Allow authenticated users to upload media
CREATE POLICY "Authenticated users can upload media" ON storage.objects
  FOR INSERT WITH CHECK (bucket_id = 'media-assets' AND auth.role() = 'authenticated');

-- Allow everyone to view media
CREATE POLICY "Media is viewable by everyone" ON storage.objects
  FOR SELECT USING (bucket_id = 'media-assets');

-- Users can update their own uploads
CREATE POLICY "Users can update own uploads" ON storage.objects
  FOR UPDATE USING (bucket_id = 'media-assets' AND auth.uid()::text = (storage.foldername(name))[1]);
```

### 5. Run the Application
```bash
npm run dev
```

Visit `http://localhost:3000` to see the application.

## 🏗️ Architecture

### Frontend (Next.js 15)
- **App Router**: Modern Next.js routing with server components
- **TypeScript**: Full type safety throughout
- **Tailwind CSS**: Utility-first styling
- **shadcn/ui**: Accessible component library

### Backend (Supabase)
- **PostgreSQL**: Primary database with Drizzle ORM
- **Authentication**: Supabase Auth with JWT tokens
- **Storage**: File uploads for media assets
- **Real-time**: Live updates for collaborative features

### Privacy & Security
- **On-Device Processing**: Face detection runs in browser
- **Consent Tokens**: Event-scoped, salted face hashes
- **RLS Policies**: Database-level access control
- **HTTPS Only**: All communications encrypted

## 📱 User Flows

### For Organizers
1. **Sign Up/Login** → Choose "Organizer" role
2. **Create Event** → Add details, actions, and generate QR code
3. **Manage Event** → View participants, media, and analytics
4. **Export Packet** → Download civic packet with metrics

### For Attendees
1. **Scan QR Code** → Join event with consent
2. **Capture Media** → Take photos/videos with privacy protection
3. **View Highlights** → See auto-generated community stories
4. **Take Action** → Participate in recommended activities

## 🔧 Development

### Database Migrations
```bash
# Generate new migration
npm run db:generate

# Apply migrations
npm run db:migrate

# Open Drizzle Studio
npm run db:studio
```

### Adding New Features
1. Update database schema in `lib/db/schema.ts`
2. Generate and run migrations
3. Update TypeScript types
4. Implement frontend components
5. Add API routes if needed

### Testing
```bash
# Run type checking
npm run type-check

# Run linting
npm run lint

# Run build
npm run build
```

## 🚀 Deployment

### Vercel (Recommended)
1. Connect your GitHub repository to Vercel
2. Add environment variables in Vercel dashboard
3. Deploy automatically on push to main

### Other Platforms
- **Railway**: Good for full-stack deployment
- **Supabase**: Hosts your database and auth
- **Cloudflare**: CDN and edge functions

## 🤝 Contributing

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request

## 📄 License

MIT License - see LICENSE file for details

## 🆘 Support

- **Documentation**: Check this README and code comments
- **Issues**: Create GitHub issues for bugs or feature requests
- **Discussions**: Use GitHub Discussions for questions

## 🎯 Roadmap

- [ ] Mobile app (React Native)
- [ ] Advanced AI features (Claude integration)
- [ ] Real-time collaboration
- [ ] Advanced analytics dashboard
- [ ] Multi-language support
- [ ] Integration with calendar apps
- [ ] Social media sharing
- [ ] Offline support

---

Built with ❤️ for community building and privacy protection.

## Detected evidence (automated analysis)

Indexed codebase: 130 recognized source files, 572 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Flask (technology) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- PostgreSQL (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (120 of 140)

```
.gitignore
app/(dashboard)/dashboard/activity/loading.tsx
app/(dashboard)/dashboard/activity/page.tsx
app/(dashboard)/dashboard/general/page.tsx
app/(dashboard)/dashboard/layout.tsx
app/(dashboard)/dashboard/page.tsx
app/(dashboard)/dashboard/security/page.tsx
app/(dashboard)/layout.tsx
app/(dashboard)/page.tsx
app/(dashboard)/pricing/page.tsx
app/(dashboard)/pricing/submit-button.tsx
app/(dashboard)/terminal.tsx
app/(login)/actions.ts
app/(login)/sign-in/page.tsx
app/(login)/sign-up/page.tsx
app/(login)/SupabaseLogin.tsx
app/api/capture-upload/route.ts
app/api/gallery/route.ts
app/api/livekit/token/route.ts
app/api/make-story/route.ts
app/api/media/route.ts
app/api/process-image/route.ts
app/api/selfie-upload/route.ts
app/api/stripe/checkout/route.ts
app/api/stripe/webhook/route.ts
app/api/team/route.ts
app/api/user/route.ts
app/auth/callback/page.tsx
app/auth/callback/route.server.ts
app/capture/page.tsx
app/event/[eventId]/gallery/page.tsx
app/event/[eventId]/GalleryPanel.tsx
app/event/[eventId]/HighlightBlock.tsx
app/event/[eventId]/page.tsx
app/event/[eventId]/ScheduleSidebar.tsx
app/gallery/page.tsx
app/globals.css
app/join/page.tsx
app/layout.tsx
app/not-found.tsx
app/organizer/components/ActionForm.tsx
app/organizer/components/ActionList.tsx
app/organizer/event/[id]/page.tsx
app/organizer/layout.tsx
app/organizer/new/page.tsx
app/organizer/page.tsx
app/page.tsx
app/upload/page.tsx
components.json
components/attendee/ActionCard.tsx
components/attendee/BadgesRow.tsx
components/attendee/BlurAllToggle.tsx
components/attendee/Camera.tsx
components/attendee/ClientFaceBlur.tsx
components/attendee/ConsentToggle.tsx
components/attendee/CTAButtons.tsx
components/attendee/EventCapture.tsx
components/attendee/FaceBlur.tsx
components/attendee/FaceBoxOverlay.tsx
components/attendee/HighlightPlayer.tsx
components/attendee/index.tsx
components/attendee/RoleSelect.tsx
components/attendee/SelfieCapture.tsx
components/attendee/SimpleCamera.tsx
components/attendee/SourcesGrid.tsx
components/attendee/UploadProgress.tsx
components/attendee/VideoRecorder.tsx
components/AuthenticatedLayout.tsx
components/AuthGate.tsx
components/AutoGeneratedStoryDisplay.tsx
components/Navigation.tsx
components/organizer/CivicPacket.tsx
components/selfie/SelfieWidget.tsx
components/ui/avatar.tsx
components/ui/badge.tsx
components/ui/button.tsx
components/ui/card.tsx
components/ui/dropdown-menu.tsx
components/ui/input.tsx
components/ui/label.tsx
components/ui/ProgressStepper.tsx
components/ui/radio-group.tsx
components/ui/tabs.tsx
components/ui/textarea.tsx
components/ui/Toggle.tsx
components/VideoGenerator.tsx
data/uploads.json
drizzle.config.ts
lib/auth/logout.ts
lib/auth/middleware.ts
lib/auth/session.ts
lib/auth/supabase.ts
lib/db/drizzle.ts
lib/db/migrations/0000_soft_the_anarchist.sql
lib/db/migrations/meta/_journal.json
lib/db/migrations/meta/0000_snapshot.json
lib/db/queries.ts
lib/db/schema.ts
lib/db/seed.ts
lib/db/setup.ts
lib/imageProcessing.ts
lib/mock.ts
lib/payments/actions.ts
lib/payments/stripe.ts
lib/recommendations.ts
lib/selfieDraft.ts
lib/state.ts
lib/supabase/client.ts
lib/types.ts
lib/uploadsStore.ts
lib/useEventRole.ts
lib/useMediaStream.ts
lib/utils.ts
LICENSE
middleware.ts
next.config.ts
package.json
postcss.config.mjs
public/face-detection-worker.js
public/uploads/demo-photo-1.html
[20 more files omitted for size]
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.67.0, @radix-ui/react-radio-group@^1.3.8, @radix-ui/react-tabs@^1.1.13, @supabase/ssr@^0.7.0, @supabase/supabase-js@^2.76.1, @tailwindcss/postcss@4.1.16, @types/node@^20.12.12, @types/react@^18.3.11, @types/react-dom@^18.3.0, autoprefixer@^10.4.20, bcryptjs@^3.0.2, class-variance-authority@^0.7.1, clsx@^2.1.1, concurrently@^8.2.2, dotenv@^16.5.0, drizzle-kit@^0.31.5, drizzle-orm@^0.43.1, eslint@^8.57.1, eslint-config-next@15.4.0-canary.47, fluent-ffmpeg@^2.1.3, gtts@^0.2.1, jose@^6.0.11, livekit@^0.0.1, livekit-server-sdk@^2.14.0, lucide-react@^0.511.0, next@15.4.0-canary.47, postcss@^8.4.47, postgres@^3.4.5, radix-ui@^1.4.2, react@18.3.1, react-dom@18.3.1, server-only@^0.0.1, stripe@^18.1.0, swr@^2.3.3, tailwind-merge@^3.3.0, tailwindcss@4.1.16, tw-animate-css@^1.3.0, typescript@^5.6.3, zod@^3.24.4
- python-face-service/requirements.txt: fastapi, insightface, numpy, opencv-python, python-dotenv, python-multipart, supabase, uvicorn

### Recent commits (newest first)

- page.tsx conflicts solved
- curly quotes fixed
- resolved package json errors
- Merge pull request #4 from shaaronl/blur-face
- Merge branch 'main' into blur-face
- Merge pull request #5 from shaaronl/himank
- added the automatic video generation and also use of LiveKit in audio generation
- Merge branch 'main' of https://github.com/shaaronl/ConsentfulCivicLens
- blurring face functionality done
- added gallery logic to events page (mock)
- Merge branch 'main' of https://github.com/shaaronl/ConsentfulCivicLens
- some stuff
- working auth flow for both orgs and attendees
- Merge pull request #3 from shaaronl/himank
- make the frontend but some issues with the login
- Merge branch 'main' of https://github.com/shaaronl/ConsentfulCivicLens
- Merge pull request #2 from shaaronl/anupriya/frontend
- adding redirect to event gallary feature on capture page
- fixed camera setup for both selfie and capture pages
- pulled recent changes and changes readme

## Key source files (fetched from GitHub, selected and truncated for size)

### SUPABASE_SETUP.md

```markdown
# 🔐 Supabase Authentication Setup Guide

Follow these steps to connect your Consentful Civic Lens app to Supabase authentication.

## Step 1: Create Supabase Project

1. Go to [supabase.com](https://supabase.com) and sign up/login
2. Click **"New Project"**
3. Choose your organization
4. Enter project details:
   - **Name**: `consentful-civic-lens`
   - **Database Password**: Create a strong password (save this!)
   - **Region**: Choose closest to your location
5. Click **"Create new project"**

Wait for the project to be created (usually takes 1-2 minutes).

## Step 2: Get Your Credentials

1. In your Supabase dashboard, go to **Settings** → **API**
2. Copy these values:
   - **Project URL** (starts with `https://`)
   - **anon public** key (starts with `eyJ`)
   - **service_role** key (starts with `eyJ`)

## Step 3: Create Environment File

Create a file called `.env.local` in your project root:

```env
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here

# Database
POSTGRES_URL=postgresql://postgres:your_password@db.your-project-ref.supabase.co:5432/postgres

# Authentication
AUTH_SECRET=your_random_32_character_secret

# Stripe (optional)
# STRIPE_SECRET_KEY=your_stripe_secret_key
# STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
```

**Replace the placeholder values with your actual credentials.**

## Step 4: Set Up Database

1. In your Supabase dashboard, go to **SQL Editor**
2. Copy the entire contents of `scripts/setup-supabase.sql`
3. Paste it into the SQL editor
4. Click **"Run"** to execute the script

This will create all the necessary tables and policies for your app.

**Note**: The script no longer includes sample user data. Users will be created automatically when they sign up through the app.

## Step 5: Configure Authentication

1. Go to **Authentication** → **Settings**
2. Under **Auth Providers**, make sure **Email** is enabled
3. Under **URL Configuration**:
   - **Site URL**: `http://localhost:3000`
   - **Redirect URLs**: Add `http://localhost:3000/auth/callback`

## Step 6: Set Up Storage

1. Go to **Storage** in your Supabase dashboard
2. Click **"Create a new bucket"**
3. Name it `media-assets`
4. Make it **Public**
5. Click **"Create bucket"**

## Step 7: Configure Storage Policies

Go back to **SQL Editor** and run this SQL:

```sql
-- Allow authenticated users to upload media
CREATE POLICY "Authenticated users can upload media" ON storage.objects
  FOR INSERT WITH CHECK (bucket_id = 'media-assets' AND auth.role() = 'authenticated');

-- Allow everyone to view media
CREATE POLICY "Media is viewable by everyone" ON storage.objects
  FOR SELECT USING (bucket_id = 'media-assets');

-- Users can update their own uploads
CREATE POLICY "Users can update own uploads" ON storage.objects
  FOR UPDATE USING (bucket_id = 'media-assets' AND auth.uid()::text = (storage.foldername(name))[1]);
```

[truncated — 2188 more characters]
```

### package.json

```
{
  "name": "consentful-civic-lens",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "dev:next": "next dev",
    "dev:fastapi": "cd python-face-service && uvicorn main:app --host 0.0.0.0 --port 8001 --reload",
    "dev:both": "concurrently \"npm run dev:next\" \"npm run dev:fastapi\"",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "setup": "node scripts/setup.js",
    "verify": "node scripts/verify-setup.js",
    "test:supabase": "node scripts/test-supabase.js",
    "seed:sample": "node scripts/create-sample-event.js",
    "seed:demo": "node scripts/seed-demo-photos.js",
    "seed:supabase": "node scripts/seed-supabase-photos.js",
    "db:setup": "npx tsx lib/db/setup.ts",
    "db:seed": "npx tsx lib/db/seed.ts",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "setup:fastapi": "cd python-face-service && pip install fastapi uvicorn insightface opencv-python numpy requests"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.67.0",
    "@radix-ui/react-radio-group": "^1.3.8",
    "@radix-ui/react-tabs": "^1.1.13",
    "@supabase/ssr": "^0.7.0",
    "@supabase/supabase-js": "^2.76.1",
    "@tailwindcss/postcss": "4.1.16",
    "autoprefixer": "^10.4.20",
    "bcryptjs": "^3.0.2",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "dotenv": "^16.5.0",
    "drizzle-orm": "^0.43.1",
    "fluent-ffmpeg": "^2.1.3",
    "gtts": "^0.2.1",
    "jose": "^6.0.11",
    "livekit": "^0.0.1",
    "livekit-server-sdk": "^2.14.0",
    "lucide-react": "^0.511.0",
    "next": "15.4.0-canary.47",
    "postcss": "^8.4.47",
    "postgres": "^3.4.5",
    "radix-ui": "^1.4.2",
    "react": "18.3.1",
    "react-dom": "18.3.1",
    "server-only": "^0.0.1",
    "stripe": "^18.1.0",
    "swr": "^2.3.3",
    "tailwind-merge": "^3.3.0",
    "tailwindcss": "4.1.16",
    "tw-animate-css": "^1.3.0",
    "zod": "^3.24.4"
  },
  "devDependencies": {
    "@types/node": "^20.12.12",
    "@types/react": "^18.3.11",
    "@types/react-dom": "^18.3.0",
    "concurrently": "^8.2.2",
    "drizzle-kit": "^0.31.5",
    "eslint": "^8.57.1",
    "eslint-config-next": "15.4.0-canary.47",
    "typescript": "^5.6.3"
  }
}

```

### python-face-service/requirements.txt

```
fastapi
uvicorn
insightface
opencv-python
numpy
python-multipart
python-dotenv
supabase
```

### app/layout.tsx

```typescript
// app/layout.tsx (only the sign-in button changed to a link)
import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";

export const metadata: Metadata = {
  title: "Consentful Civic Lens",
  description: "Consent-first, visual community forum — from moments to actions.",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-gradient-to-b from-white via-yellow-50/30 to-white">
        <header className="sticky top-0 z-50 backdrop-blur-xl bg-white/80 border-b border-gray-200 shadow-sm">
          <div className="mx-auto max-w-6xl px-4 py-4">
            <div className="flex items-center justify-between">
              <Link href="/" className="flex items-center gap-2 group">
                <div className="h-8 w-8 rounded-xl bg-gradient-to-br from-yellow-400 to-amber-500 shadow-lg group-hover:scale-110 transition-transform duration-300" />
                <span className="font-bold text-xl text-gray-900 group-hover:text-amber-600 transition-colors">
                  Consentful Civic Lens
                </span>
              </Link>

              {/* keep public nav minimal; protected routes will redirect anyway */}
              <div className="flex items-center gap-2">
                <a
                  href="/sign-in"
                  className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gradient-to-r from-yellow-400 to-amber-400 text-gray-900 shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105"
                >
                  Sign in
                </a>
                <a
                  href="/sign-up"
                  className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-200 text-gray-900 shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105"
                >
                  Get Started
                </a>
              </div>
            </div>
          </div>
        </header>

        <main>{children}</main>

        <footer className="bg-gray-900 text-white py-12 mt-20">
          <div className="mx-auto max-w-6xl px-4">
            <div className="flex flex-col md:flex-row items-center justify-between gap-4">
              <div className="flex items-center gap-2">
                <div className="h-8 w-8 rounded-xl bg-gradient-to-br from-yellow-400 to-amber-500 shadow-lg" />
                <span className="font-bold text-lg">Consentful Civic Lens</span>
              </div>
              <p className="text-sm text-gray-400">
                © {new Date().getFullYear()} Consentful Civic Lens · Built with ❤️
              </p>
            </div>
          </div>
        </footer>
      </body>
    </html>
  );
}
```

### python-face-service/main.py

```python
from fastapi import FastAPI, UploadFile, File, Form, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from insightface.app import FaceAnalysis
import cv2
import numpy as np
import io
import json
import requests
import os
from dotenv import load_dotenv
from supabase import create_client, Client

load_dotenv()

app = FastAPI(title="Face Detection Service")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

# Initialize InsightFace
face_app = FaceAnalysis(name="buffalo_l")
face_app.prepare(ctx_id=-1, det_size=(640, 640))

# Initialize Supabase
supabase_url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
supabase_key = os.getenv('SUPABASE_SERVICE_ROLE_KEY')
supabase: Client = create_client(supabase_url, supabase_key) if supabase_url and supabase_key else None

def get_consent_embeddings(event_id: str):
    """Fetch consent embeddings from Supabase database"""
    if not supabase:
        return []
    
    try:
        response = supabase.table('consent_tokens').select('face_embedding').eq('event_id', event_id).execute()
        
        consent_embeddings = []
        for token in response.data:
            if token.get('face_embedding'):
                try:
                    embedding = np.array(json.loads(token['face_embedding']))
                    consent_embeddings.append(embedding)
                except Exception as e:
                    print(f"Error parsing embedding: {e}")
        
        return consent_embeddings
    except Exception as e:
        print(f"Error fetching consent embeddings: {e}")
        return []



@app.post("/process-consent-selfie")
async def process_consent_selfie(photo: UploadFile = File(...), user_id: str = Form(...), event_id: str = Form(...)):
    """Process consent selfie and store face embedding"""
    try:
        if not supabase:
            return {"error": "Supabase not configured"}
        
        # Read and decode image
        image_data = await photo.read()
        nparr = np.frombuffer(image_data, np.uint8)
        image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
        
        if image is None:
            return {"error": "Could not decode image"}
        
        # Detect faces
        faces = face_app.get(image)
        if not faces:
            return {"error": "No face found in selfie"}
        
        # Extract and normalize embedding from first face
        face = faces[0]
        embedding = face["embedding"]
        norm = np.linalg.norm(embedding) + 1e-12
        normalized_embedding = embedding / norm
        
        # Store in database
        supabase.table('consent_tokens').upsert({
            'user_id': user_id,
            'event_id': event_id,
            'face_embedding': json.dumps(normalized_embedding.tolist()),
            'consented': True
        }).execute()
        
        return {"success": True, "message": "Consent selfie processed"}
        
    except Exception as e:
        return {"error": f"Processing error: {str(e)}"}

@app.post("/auto_blur")
async def auto_blur(photo: UploadFile = File(...), consent_tokens: str = Form(default="[]"), event_id: str = Form(default="")):
    """Blur faces that DON'T match consent photos (CCL logic)"""
    image_data = await photo.read()
    nparr = np.frombuffer(image_data, np.uint8)
    image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
    
    # Get consent embeddings and process faces
    consent_embeddings = get_consent_embeddings(event_id)
    faces = face_app.get(image)
    H, W = image.shape[:2]
    blurred_count = 0
    
    for face in faces:
        x1, y1, x2, y2 = map(int, face["bbox"])
        face_embedding = face["embedding"]
        norm = np.linalg.norm(face_embedding) + 1e-12
        normalized_face = face_embedding / norm
        
        # Check if face matches any consent embedding
        has_consent = False
        if consent_embeddings:
            for consent_embedding in enumerate(consent_embeddings):
                similarity = float(np.dot(consent_embedding[1], normalized_face))
                if similarity > 0.35:  # Similarity threshold
                    has_consent = True
                    break
        
        # Blur faces without consent
        if not has_consent:
            center_x = (x1 + x2) // 2
            center_y = (y1 + y2) // 2
            radius = max((x2 - x1), (y2 - y1)) // 2 + 20
            
            mask = np.zeros((H, W), dtype=np.uint8)
            cv2.circle(mask, (center_x, center_y), radius, 255, -1)
            
            blurred_img = cv2.GaussianBlur(image, (99, 99), 30)
            image = np.where(mask[..., None] == 255, blurred_img, image)
            blurred_count += 1
    _, buffer = cv2.imencode('.jpg', image)
    return StreamingResponse(
        io.BytesIO(buffer.tobytes()),
        media_type="image/jpeg",
        headers={
            "X-Faces-Detected": str(len(faces)),
            "X-Blurred-Faces": str(blurred_count)
        }
    )



@app.get("/test")
async def test():
    return {"status": "FastAPI service is working", "supabase_connected": supabase is not None}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8001)
```

### app/page.tsx

```typescript
// app/page.tsx
"use client";

import React, { useMemo } from "react";

function ButterPill({ children }: { children: React.ReactNode }) {
  return (
    <span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-br from-yellow-100 to-amber-100 text-gray-900 border border-yellow-200 shadow-sm hover:shadow-md transition-all duration-300 hover:scale-105">
      {children}
    </span>
  );
}

function FeatureCard({
  title,
  desc,
  icon,
  gradient,
}: {
  title: string;
  desc: string;
  icon: React.ReactNode;
  gradient: string;
}) {
  return (
    <div className="group relative rounded-3xl border border-gray-200 bg-white p-6 shadow-sm hover:shadow-xl transition-all duration-500 hover:-translate-y-2 overflow-hidden">
      <div className={`absolute inset-0 opacity-0 group-hover:opacity-5 transition-opacity duration-500 ${gradient}`} />
      <div className="relative">
        <div className="h-12 w-12 rounded-2xl flex items-center justify-center bg-gradient-to-br from-yellow-100 to-amber-100 border border-yellow-200 text-amber-600 mb-4 group-hover:scale-110 group-hover:rotate-6 transition-transform duration-500">
          {icon}
        </div>
        <h3 className="font-bold text-lg text-gray-900">{title}</h3>
        <p className="text-sm text-gray-600 mt-2 leading-relaxed">{desc}</p>
      </div>
    </div>
  );
}

function StepCard({ number, title, desc }: { number: number; title: string; desc: string }) {
  return (
    <div className="relative group">
      <div className="absolute -inset-1 bg-gradient-to-r from-yellow-400 to-amber-400 rounded-3xl opacity-0 group-hover:opacity-20 blur transition duration-500" />
      <div className="relative p-6 bg-white rounded-3xl border-2 border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
        <div className="absolute -left-4 -top-4 h-10 w-10 rounded-full bg-gradient-to-br from-yellow-400 to-amber-500 text-white font-bold flex items-center justify-center text-lg shadow-lg ring-4 ring-white">
          {number}
        </div>
        <h3 className="font-bold text-gray-900 mt-3 text-lg">{title}</h3>
        <p className="text-sm text-gray-600 mt-2 leading-relaxed">{desc}</p>
      </div>
    </div>
  );
}

// tiny deterministic PRNG so SSR and CSR render identically
function mulberry32(seed: number) {
  return function () {
    let t = (seed += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export default function Landing() {
  // generate a stable set of 20 dot positions & delays
  const dots = useMemo(() => {
    const rng = mulberry32(42);
    return Array.from({ length: 20 }, () => ({
      left: `${(rng() * 100).toFixed(3)}%`,
      top: `${(rng() * 100).toFixed(3)}%`,
      delay: `${(rng() * 2).toFixed(3)}s`,
    }));
  }, []);

  return (
    <div className="bg-gradient-to-b from-white via-yellow-50/30 to-white min-h-screen">
      {/* Hero */}
      <section className="relative overflow-hidden">
        <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(251,191,36,0.3),transparent_50%)]" />
        <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(252,211,77,0.2),transparent_50%)]" />

        <div className="relative mx-auto max-w-6xl px-4 pt-16 pb-20">
          <div className="flex flex-col-reverse lg:flex-row items-center gap-12">
            <div className="lg:w-3/5 space-y-6">
              <div className="flex flex-wrap gap-2">
                <ButterPill>🛡️ Consent-first</ButterPill>
                <ButterPill>♿ Accessible</ButterPill>
                <ButterPill>⚡ Action-oriented</ButterPill>
              </div>

              <h1 className="text-5xl lg:text-6xl font-black tracking-tight leading-tight text-gray-900">
                Make memories that{" "}
                <span className="relative inline-block">
                  <span className="relative z-10">respect people</span>
                  <span className="absolute -bottom-2 left-0 right-0 h-3 bg-gradient-to-r from-yellow-300 to-amber-300 -rotate-1 opacity-70" />
                </span>
                {" "}
— and turn them into help.
              </h1>

              <p className="text-lg text-gray-600 leading-relaxed max-w-2xl">
                CCL is a visual community forum that blurs non-consenting faces on device, creates accessible event
                highlights, and recommends concrete actions—like volunteer shifts or supply drop-offs.
              </p>

              <div className="flex flex-wrap gap-3 pt-2">
                <button className="group px-6 py-3.5 rounded-2xl text-base font-bold text-gray-900 bg-gradient-to-r from-yellow-400 to-amber-400 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105">
                  Join an event
                  <span className="inline-block ml-2 group-hover:translate-x-1 transition-transform">→</span>
                </button>
                <button className="px-6 py-3.5 rounded-2xl text-base font-semibold border-2 border-gray-300 bg-white hover:bg-gray-50 hover:border-yellow-400 shadow-sm hover:shadow-md transition-all duration-300">
                  View example thread
                </button>
                <button className="px-6 py-3.5 rounded-2xl text-base font-semibold border-2 border-gray-300 bg-white hover:bg-gray-50 hover:border-yellow-400 shadow-sm hover:shadow-md transition-all duration-300">
                  Open capture
                </button>
              </div>
            </div>

            {/* Right: Mock Device */}
            <div className="lg:w-2/5">
              <div className="relative group">
                <div className="absolute -inset-4 bg-gradient-to-r from-yellow-400 via-amber-400 to-yellow-400 rounded-3xl opacity-20 blur-2xl group-hover:opacity-30 transition duration-500" />
        
[truncated — 13763 more characters]
```

### app/organizer/layout.tsx

```typescript
"use client";
// Temporary pass-through layout so dev works without Supabase configured.
// Replace with role guard once Supabase is installed and env is set.
export default function OrganizerLayout({ children }:{children:React.ReactNode}){
  return <>{children}</>;
}

```

### components/attendee/index.tsx

```typescript
export { default as CTAButtons } from "./CTAButtons";
export { default as ConsentToggle } from "./ConsentToggle";
export { default as SelfieCapture } from "./SelfieCapture";
export { default as RoleSelect } from "./RoleSelect";
export { default as BlurAllToggle } from "./BlurAllToggle";
export { default as UploadProgress } from "./UploadProgress";
export { default as EventCapture } from "./EventCapture";

```

### app/(dashboard)/layout.tsx

```typescript
'use client';

import Link from 'next/link';
import { use, useState, Suspense } from 'react';
import { Button } from '@/components/ui/button';
import { CircleIcon, Home, LogOut } from 'lucide-react';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { signOut } from '@/app/(login)/actions';
import { useRouter } from 'next/navigation';
import { User } from '@/lib/db/schema';
import useSWR, { mutate } from 'swr';

const fetcher = (url: string) => fetch(url).then((res) => res.json());

function UserMenu() {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const { data: user } = useSWR<User>('/api/user', fetcher);
  const router = useRouter();

  async function handleSignOut() {
    await signOut();
    mutate('/api/user');
    router.push('/');
  }

  if (!user) {
    return (
      <>
        <Link
          href="/pricing"
          className="text-sm font-medium text-gray-700 hover:text-gray-900"
        >
          Pricing
        </Link>
        <Button asChild className="rounded-full">
          <Link href="/sign-up">Sign Up</Link>
        </Button>
      </>
    );
  }

  return (
    <DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
      <DropdownMenuTrigger>
        <Avatar className="cursor-pointer size-9">
          <AvatarImage alt={user.name || ''} />
          <AvatarFallback>
            {user.email
              .split(' ')
              .map((n) => n[0])
              .join('')}
          </AvatarFallback>
        </Avatar>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="flex flex-col gap-1">
        <DropdownMenuItem className="cursor-pointer">
          <Link href="/dashboard" className="flex w-full items-center">
            <Home className="mr-2 h-4 w-4" />
            <span>Dashboard</span>
          </Link>
        </DropdownMenuItem>
        <form action={handleSignOut} className="w-full">
          <button type="submit" className="flex w-full">
            <DropdownMenuItem className="w-full flex-1 cursor-pointer">
              <LogOut className="mr-2 h-4 w-4" />
              <span>Sign out</span>
            </DropdownMenuItem>
          </button>
        </form>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

function Header() {
  return (
    <header className="border-b border-gray-200">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
        <Link href="/" className="flex items-center">
          <CircleIcon className="h-6 w-6 text-orange-500" />
          <span className="ml-2 text-xl font-semibold text-gray-900">ACME</span>
        </Link>
        <div className="flex items-center space-x-4">
          <Suspense fallback={<div className="h-9" />}>
            <UserMenu />
          </Suspense>
        </div>
      </div>
    </header>
  );
}

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <section className="flex flex-col min-h-screen">
      <Header />
      {children}
    </section>
  );
}

```

### app/gallery/page.tsx

```typescript
"use client";
import { useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { AuthenticatedLayout } from "@/components/AuthenticatedLayout";
import AuthGate from "@/components/AuthGate";

interface MediaItem {
  id: number;
  event_id: string;
  uploader_id: string;
  file_url: string;
  media_type: 'photo' | 'video';
  processed: boolean;
  faces_detected: number;
  faces_blurred: number;
  created_at: string;
}

export default function GalleryPage() {
  const sp = useSearchParams();
  const eventId = sp.get("eventId") || "test-event";
  
  const [media, setMedia] = useState<MediaItem[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchMedia();
  }, [eventId]);

  const fetchMedia = async () => {
    try {
      const response = await fetch(`/api/media?eventId=${eventId}`);
      const data = await response.json();
      setMedia(data.media || []);
    } catch (error) {
      console.error('Failed to fetch media:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <AuthGate requireAuth redirectTo="/">
      <AuthenticatedLayout>
        <div className="max-w-6xl mx-auto p-4 space-y-6">
          <div className="text-center">
            <h1 className="text-3xl font-bold text-gray-900">Event Gallery</h1>
            <p className="text-gray-600 mt-2">
              All photos and videos from this event (with privacy protection)
            </p>
          </div>

          {loading ? (
            <div className="text-center py-8">Loading media...</div>
          ) : media.length === 0 ? (
            <Card>
              <CardContent className="text-center py-8">
                <p className="text-gray-600">No media uploaded yet for this event.</p>
              </CardContent>
            </Card>
          ) : (
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
              {media.map((item) => (
                <Card key={item.id} className="overflow-hidden">
                  <div className="relative">
                    {item.media_type === 'photo' ? (
                      <img
                        src={item.file_url}
                        alt="Event photo"
                        className="w-full h-48 object-cover"
                      />
                    ) : (
                      <video
                        src={item.file_url}
                        className="w-full h-48 object-cover"
                        controls
                      />
                    )}
                    
                    <div className="absolute top-2 right-2 flex gap-1">
                      <Badge variant="secondary">
                        {item.media_type}
                      </Badge>
                      {item.processed && (
                        <Badge variant="default" className="bg-green-600">
                          Processed
                        </Badge>
                      )}
                    </div>
                  </div>
                  
                  <CardContent className="p-4">
                    <div className="text-sm text-gray-600 space-y-1">
                      <div>Faces detected: {item.faces_detected}</div>
                      <div>Faces blurred: {item.faces_blurred}</div>
                      <div>Uploaded: {new Date(item.created_at).toLocaleDateString()}</div>
                    </div>
                  </CardContent>
                </Card>
              ))}
            </div>
          )}
        </div>
      </AuthenticatedLayout>
    </AuthGate>
  );
}
```

[120 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]