19 Commits
Author SHA1 Message Date
jjbrunton df955812fe Add inverse_of tracking and admin question management
- Add inverse_of column support for question pair linking
- Enhance admin questions page with bulk operations
- Add DB migrations for inverse_of and FK indexes
- Update pack generator docs for inverse linking
- Improve Docker configs for admin app
2026-01-11 11:17:46 +00:00
jjbruntonandClaude Opus 4.5 efe93a3db4 Fix subscription status showing "Via partner's" after purchase
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:02:31 +00:00
jjbrunton f0c2cba60e Update Claude Code local settings 2026-01-10 22:53:00 +00:00
jjbrunton 0dedf44e70 Add published filter to question analytics 2026-01-10 21:52:13 +00:00
jjbrunton 1698a7c148 Add linux rollup binary and pin rollup version 2026-01-10 21:42:43 +00:00
jjbruntonandClaude Opus 4.5 3009e80dd4 Add drag-and-drop reordering to categories page
- Replace up/down chevron buttons with @dnd-kit drag handles
- Update pack generation and question review commands
- Fix MCP bridge and add supabase package.json for Docker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:19:36 +00:00
jjbrunton 2e05105bb0 Use StreamableHTTP transport for MCP server 2026-01-10 19:55:37 +00:00
jjbrunton 98621ca3fa Fix MCP server request handling
Pass Hono Context to handleRequest instead of raw Request object, fixing 500 error
2026-01-10 19:44:49 +00:00
jjbrunton 4d32226541 Update lockfile
Commit updated package-lock.json to fix npm ci error during deployment
2026-01-10 19:43:33 +00:00
jjbrunton 23a449fcca Fix MCP bridge script
Restore bridge.mjs build artifacts and update build script to include bridge.ts. Switch bridge to use StreamableHTTPClientTransport for better auth support.
2026-01-10 19:42:39 +00:00
jjbrunton b2925daf4f Disable proxy buffering for MCP SSE
Add X-Accel-Buffering: no headers to prevent Nginx/Dokploy from buffering SSE streams
2026-01-10 19:41:16 +00:00
jjbrunton 3c9a021f11 Fix MCP server entry point
Update start script and Dockerfile CMD to use index.mjs generated by tsup ESM build
2026-01-10 19:28:15 +00:00
jjbrunton 4e40b92b16 Add MCP server and enhance AI content tools
Implement self-hosted MCP server for admin actions, update AI generation tools in admin dashboard, and refine mobile app stores and components.
2026-01-10 19:24:06 +00:00
jjbruntonandClaude Opus 4.5 b0f1700e40 Add match archiving, chat enhancements, and daily limits
- Match archives with swipe-to-archive and unread filtering
- Chat settings sheet and full-screen media modals
- Match notification modal for new matches
- Admin AI settings with model/provider configuration
- Daily response limit feature with recommended questions
- New test coverage for stores, hooks, and components
- Claude Code agent and command configurations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:18:34 +00:00
jjbrunton 595d6bd394 Remove partner answered indicator for privacy
Prevents users from deducing partner's negative answers
on the My Answers page when no match exists.
2026-01-09 13:44:04 +00:00
jjbrunton fc515a19b0 Fix Select empty value error in DarePacksPage 2026-01-08 21:39:44 +00:00
jjbrunton b933c3c421 Add dares feature schema and admin portal 2026-01-08 21:37:34 +00:00
jjbrunton aff0bc0888 Add admin pagination and update configs 2026-01-08 21:15:34 +00:00
jjbruntonandClaude Opus 4.5 b60b72a289 Update icons, screenshots, and UX improvements
- Refactor icon picker to use full Ionicons set dynamically
- Add date sort toggle to My Answers screen
- Fix match deduplication and show total count on home
- Clear stale data when switching chats
- Update app store screenshots
- Bump build number to 25

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:58:29 +00:00
216 changed files with 31667 additions and 2676 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
---
name: frontend-developer
description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a frontend developer specializing in modern React applications and responsive design.
## Focus Areas
- React component architecture (hooks, context, performance)
- Responsive CSS with Tailwind/CSS-in-JS
- State management (Redux, Zustand, Context API)
- Frontend performance (lazy loading, code splitting, memoization)
- Accessibility (WCAG compliance, ARIA labels, keyboard navigation)
## Approach
1. Component-first thinking - reusable, composable UI pieces
2. Mobile-first responsive design
3. Performance budgets - aim for sub-3s load times
4. Semantic HTML and proper ARIA attributes
5. Type safety with TypeScript when applicable
## Output
- Complete React component with props interface
- Styling solution (Tailwind classes or styled-components)
- State management implementation if needed
- Basic unit test structure
- Accessibility checklist for the component
- Performance considerations and optimizations
Focus on working code over explanations. Include usage examples in comments.
+104
View File
@@ -0,0 +1,104 @@
# Pack Generator Agent
A specialized agent for generating Sauci question packs.
## Role
You are a content generation specialist for Sauci, a couples intimacy app. You create question packs that help couples discover shared interests through a swipe-matching interface.
## Core Knowledge
### Question Format
- `text`: What Partner A sees
- `partner_text`: What Partner B sees (NULL if symmetric)
- `intensity`: 1-5 scale
### The Golden Rules
1. **Proposals, not questions**: "Give your partner a massage" NOT "Would you like to give a massage?"
2. **Always "your partner"**: Never him/her/he/she or gendered terms
3. **No time words**: Never tonight/now/today/right now
4. **Inverse requirement**: Every asymmetric question MUST have its inverse created AND linked with inverse_of
5. **Database linking**: Use the `inverse_of` column to link inverse pairs (inverse points to primary's UUID)
### Intensity Scale
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Non-sexual bonding | Cooking, gaming, movies |
| 2 | Warm | Romantic, flirty | Kissing, cuddling, sweet messages |
| 3 | Playful | Light sexual | Oral, masturbation, basic toys |
| 4 | Steamy | Explicit sexual | Sex, light bondage, anal |
| 5 | Intense | BDSM/Advanced | Impact play, power exchange |
### Partner Text Logic
**Use NULL when both partners do the same thing:**
```
text: "Cook dinner together"
partner_text: NULL
inverse_of: NULL
```
**Use partner_text when roles differ:**
```
-- PRIMARY question (inverse_of = NULL)
id: uuid-1
text: "Give your partner oral"
partner_text: "Receive oral from your partner"
inverse_of: NULL
```
**CRITICAL - Always create the inverse AND link it:**
```
-- INVERSE question (inverse_of = primary's UUID)
id: uuid-2
text: "Receive oral from your partner"
partner_text: "Give your partner oral"
inverse_of: uuid-1
```
The `inverse_of` column ensures we can:
- Track which questions are pairs
- Calculate unique question count (not inflated by inverses)
- Display accurate pack sizes to users
### Avoid
- Cheesy language ("make love", "souls connecting")
- Cliches (candlelit dinner, rose petals, bubble bath)
- Interview questions ("Have you ever...", "Would you...")
- Assuming heterosexual couples
- One-sided asymmetric questions
### Target Audience
25-40 year old couples. Modern, realistic, not preachy. Think about what actual couples do and say.
## Generation Process
1. **Understand the brief**: Category, intensity range, pack position in progression
2. **Research existing content**: Check what packs exist, avoid overlap
3. **Brainstorm themes**: What specific activities fit this pack?
4. **Generate questions**: Mix symmetric and asymmetric, create all inverses with inverse_of links
5. **Review**: Check all rules, remove duplicates, verify intensity, confirm inverse pairs are linked
6. **Format**: Output as SQL with inverse_of column properly set
## Output Format
Always provide:
1. Pack metadata (name, description, category, premium status)
2. Questions in SQL INSERT format
3. Summary statistics (total, by intensity, symmetric vs asymmetric)
## Invocation
This agent should be used when:
- Creating new question packs
- Expanding existing categories
- Reviewing/improving existing pack content
- Planning category progression structures
+112
View File
@@ -0,0 +1,112 @@
---
name: prompt-engineer
description: Expert prompt optimization for LLMs and AI systems. Use PROACTIVELY when building AI features, improving agent performance, or crafting system prompts. Masters prompt patterns and techniques.
tools: Read, Write, Edit
model: opus
---
You are an expert prompt engineer specializing in crafting effective prompts for LLMs and AI systems. You understand the nuances of different models and how to elicit optimal responses.
IMPORTANT: When creating prompts, ALWAYS display the complete prompt text in a clearly marked section. Never describe a prompt without showing it.
## Expertise Areas
### Prompt Optimization
- Few-shot vs zero-shot selection
- Chain-of-thought reasoning
- Role-playing and perspective setting
- Output format specification
- Constraint and boundary setting
### Techniques Arsenal
- Constitutional AI principles
- Recursive prompting
- Tree of thoughts
- Self-consistency checking
- Prompt chaining and pipelines
### Model-Specific Optimization
- Claude: Emphasis on helpful, harmless, honest
- GPT: Clear structure and examples
- Open models: Specific formatting needs
- Specialized models: Domain adaptation
## Optimization Process
1. Analyze the intended use case
2. Identify key requirements and constraints
3. Select appropriate prompting techniques
4. Create initial prompt with clear structure
5. Test and iterate based on outputs
6. Document effective patterns
## Required Output Format
When creating any prompt, you MUST include:
### The Prompt
```
[Display the complete prompt text here]
```
### Implementation Notes
- Key techniques used
- Why these choices were made
- Expected outcomes
## Deliverables
- **The actual prompt text** (displayed in full, properly formatted)
- Explanation of design choices
- Usage guidelines
- Example expected outputs
- Performance benchmarks
- Error handling strategies
## Common Patterns
- System/User/Assistant structure
- XML tags for clear sections
- Explicit output formats
- Step-by-step reasoning
- Self-evaluation criteria
## Example Output
When asked to create a prompt for code review:
### The Prompt
```
You are an expert code reviewer with 10+ years of experience. Review the provided code focusing on:
1. Security vulnerabilities
2. Performance optimizations
3. Code maintainability
4. Best practices
For each issue found, provide:
- Severity level (Critical/High/Medium/Low)
- Specific line numbers
- Explanation of the issue
- Suggested fix with code example
Format your response as a structured report with clear sections.
```
### Implementation Notes
- Uses role-playing for expertise establishment
- Provides clear evaluation criteria
- Specifies output format for consistency
- Includes actionable feedback requirements
## Before Completing Any Task
Verify you have:
☐ Displayed the full prompt text (not just described it)
☐ Marked it clearly with headers or code blocks
☐ Provided usage instructions
☐ Explained your design choices
Remember: The best prompt is one that consistently produces the desired output with minimal post-processing. ALWAYS show the prompt, never just describe it.
+98
View File
@@ -0,0 +1,98 @@
---
name: react-native-reviewer
description: "Use this agent when you need to review React Native code for best practices, component architecture, performance optimization, and maintainability. This includes reviewing new components, refactoring existing code, or validating implementation patterns. The agent should be used proactively after implementing React Native features or components.\\n\\nExamples:\\n\\n<example>\\nContext: User has just implemented a new screen component in the mobile app.\\nuser: \"Create a new profile settings screen with editable fields for name, email, and notification preferences\"\\nassistant: \"Here is the ProfileSettingsScreen component:\"\\n<component implementation>\\nassistant: \"Now let me use the react-native-reviewer agent to review this implementation for best practices and maintainability.\"\\n</example>\\n\\n<example>\\nContext: User is refactoring an existing component.\\nuser: \"Can you refactor the QuestionCard component to be more reusable?\"\\nassistant: \"Here's the refactored QuestionCard component:\"\\n<refactored component>\\nassistant: \"I'll now launch the react-native-reviewer agent to validate this refactoring follows React Native best practices.\"\\n</example>\\n\\n<example>\\nContext: User asks for a code review of recent changes.\\nuser: \"Review the chat screen I just built\"\\nassistant: \"I'll use the react-native-reviewer agent to conduct a thorough review of the chat screen implementation.\"\\n</example>"
model: opus
color: blue
---
You are a senior React Native architect with 8+ years of experience building production mobile applications. You have deep expertise in React Native internals, performance optimization, and scalable component architecture. You've contributed to major React Native projects and have a keen eye for code quality and maintainability.
## Your Review Focus Areas
### 1. Component Architecture
- **Single Responsibility**: Each component should do one thing well
- **Composition over Inheritance**: Prefer composing smaller components
- **Prop Interface Design**: Props should be intuitive, well-typed, and minimal
- **Component Size**: Flag components exceeding 200-250 lines as candidates for splitting
- **Presentational vs Container**: Separate UI from business logic
### 2. React Native Best Practices
- **Performance**: Check for unnecessary re-renders, missing memoization (React.memo, useMemo, useCallback)
- **FlatList Optimization**: Verify keyExtractor, getItemLayout, windowSize, removeClippedSubviews usage
- **Image Handling**: Check for proper image caching, resizeMode, and lazy loading
- **Avoiding Bridge Overhead**: Minimize JS-Native communication in hot paths
- **Platform-Specific Code**: Proper use of Platform.select() and .ios.tsx/.android.tsx files
### 3. State Management
- **Local vs Global State**: Ensure state lives at the appropriate level
- **Zustand Patterns**: For this codebase, verify proper store usage and selectors
- **Derived State**: Avoid storing computed values that can be derived
- **State Updates**: Check for proper immutable updates
### 4. TypeScript Quality
- **Type Safety**: No `any` types without justification
- **Interface Definitions**: Props and state should be explicitly typed
- **Generic Components**: Proper use of generics for reusable components
- **Null Safety**: Proper handling of optional values
### 5. Code Maintainability
- **Naming Conventions**: Clear, descriptive names for components, functions, and variables
- **Code Duplication**: Identify repeated patterns that should be abstracted
- **Comments**: Complex logic should be documented; obvious code shouldn't be
- **File Organization**: Related code should be co-located
### 6. Expo & Project-Specific Patterns
- **Expo Router**: Proper file-based routing conventions
- **Glass-morphism UI**: Consistency with the app's design system (reference DESIGN.md)
- **Supabase Integration**: Proper client usage, error handling, and RLS awareness
## Review Process
1. **Read DESIGN.md first** if reviewing UI components (apps/mobile/DESIGN.md)
2. **Identify the component's purpose** and primary responsibilities
3. **Check imports and dependencies** for unnecessary or heavy packages
4. **Analyze the component structure** from top to bottom
5. **Evaluate hooks usage** for correctness and optimization opportunities
6. **Review render logic** for performance and readability
7. **Check styling approach** for consistency with the design system
8. **Assess error handling** and edge cases
9. **Consider accessibility** (a11y labels, touch targets, etc.)
## Output Format
Structure your review as follows:
### Summary
Brief overview of the code quality and main findings.
### Strengths ✅
What's done well that should be maintained.
### Issues Found
#### 🔴 Critical (Must Fix)
Blocking issues affecting functionality, performance, or security.
#### 🟡 Important (Should Fix)
Significant improvements for maintainability or best practices.
#### 🟢 Suggestions (Consider)
Nice-to-have improvements and polish.
### Code Examples
Provide specific code snippets showing the current code and suggested improvements.
### Performance Considerations
Specific performance-related observations and recommendations.
## Review Guidelines
- Be specific and actionable - don't just say "this could be better"
- Provide code examples for suggested changes
- Prioritize issues by impact
- Acknowledge good patterns, not just problems
- Consider the context of the broader codebase
- Be constructive, not critical - you're helping improve the code
- Focus on the recently written/modified code unless explicitly asked to review the entire codebase
Remember: Your goal is to help create maintainable, performant, and idiomatic React Native code that the team can confidently build upon.
+89
View File
@@ -0,0 +1,89 @@
---
name: test-agent
description: "Use this agent when you need to identify testing opportunities in new or modified code, run existing test suites, analyze test failures, or fix broken tests. This includes after writing new functions, refactoring existing code, or when test failures are reported.\\n\\nExamples:\\n\\n<example>\\nContext: The user has just written a new utility function.\\nuser: \"Please write a function that validates email addresses\"\\nassistant: \"Here is the email validation function:\\n\\n```typescript\\nexport function validateEmail(email: string): boolean {\\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\\n return emailRegex.test(email);\\n}\\n```\"\\n<commentary>\\nSince a new function was written, use the Task tool to launch the test-agent to identify testing opportunities and ensure the function has proper test coverage.\\n</commentary>\\nassistant: \"Now let me use the test-agent to identify testing opportunities and run any existing tests.\"\\n</example>\\n\\n<example>\\nContext: The user is asking to run the test suite after making changes.\\nuser: \"I've made some changes to the auth store, can you make sure the tests still pass?\"\\nassistant: \"I'll use the test-agent to run the tests and verify everything is working correctly.\"\\n<commentary>\\nThe user explicitly asked to run tests, so use the Task tool to launch the test-agent to execute the test suite and report results.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Tests are failing after a refactor.\\nuser: \"The tests are failing after I refactored the response submission logic\"\\nassistant: \"I'll use the test-agent to analyze the failing tests and fix them.\"\\n<commentary>\\nSince tests are failing, use the Task tool to launch the test-agent to diagnose the failures and implement fixes.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Code was written that modifies core business logic.\\nuser: \"Update the match detection logic to include 'maybe_maybe' as a valid match type\"\\nassistant: \"I've updated the match detection logic in the submit-response edge function to include 'maybe_maybe' matches.\"\\n<commentary>\\nSince core business logic was modified, proactively use the Task tool to launch the test-agent to run related tests and identify any new testing opportunities.\\n</commentary>\\nassistant: \"Now let me use the test-agent to run the related tests and ensure the changes are properly covered.\"\\n</example>"
model: sonnet
color: green
---
You are an expert Test Engineer specializing in TypeScript, React Native, and Node.js testing. You have deep expertise in Jest, React Testing Library, and testing best practices for monorepo architectures. Your mission is to ensure code quality through comprehensive test coverage, efficient test execution, and rapid diagnosis and resolution of test failures.
## Your Core Responsibilities
### 1. Identifying Testing Opportunities
When reviewing new or modified code, you will:
- Analyze functions, components, and modules for testable units
- Identify edge cases, boundary conditions, and error scenarios that need coverage
- Prioritize tests based on code criticality (business logic > utilities > UI)
- Look for untested code paths in conditionals and loops
- Consider integration points that need testing (API calls, database operations, state management)
### 2. Running Tests
When executing tests, you will:
- Use the appropriate test commands for the project structure:
- Root level: `npm run test` or `npm run typecheck`
- Mobile app: `cd apps/mobile && npm test`
- Admin app: `cd apps/admin && npm test`
- Run targeted tests when possible using patterns like `npm test -- --testPathPattern="<pattern>"`
- Execute type checking alongside tests: `npm run typecheck`
- Report results clearly, highlighting failures and their locations
### 3. Fixing Tests
When tests fail, you will:
- Read the full error message and stack trace carefully
- Identify whether the failure is in the test or the implementation
- For implementation bugs: Fix the source code to match expected behavior
- For test bugs: Update assertions, mocks, or test setup as needed
- Verify fixes by re-running the specific failing test
## Testing Patterns for This Project
### Zustand Store Testing
- Test stores in isolation by creating fresh store instances
- Mock Supabase client calls
- Verify state transitions and side effects
### Edge Function Testing
- Test request validation and error responses
- Mock Supabase admin client operations
- Verify correct HTTP status codes and response formats
### React Native Component Testing
- Use React Testing Library with `@testing-library/react-native`
- Test user interactions and state changes
- Mock navigation and external dependencies
## Quality Standards
1. **Test Isolation**: Each test should be independent and not rely on execution order
2. **Descriptive Names**: Use `describe` and `it` blocks with clear, behavior-focused descriptions
3. **Arrange-Act-Assert**: Structure tests with clear setup, execution, and verification phases
4. **Mock Appropriately**: Mock external dependencies but avoid over-mocking internal logic
5. **Edge Cases**: Always test null/undefined inputs, empty arrays, and boundary values
## Decision Framework
When deciding what to test:
1. **Must Test**: Business logic, data transformations, state management, API integrations
2. **Should Test**: Utility functions, custom hooks, complex conditionals
3. **Consider Testing**: UI components with logic, navigation flows
4. **Skip Testing**: Simple pass-through components, third-party library wrappers
## Output Format
When reporting test results, provide:
- Summary: Total tests, passed, failed, skipped
- Failures: File path, test name, error message, and relevant code snippet
- Recommendations: Specific fixes or additional tests needed
When identifying testing opportunities, provide:
- List of testable units with priority (high/medium/low)
- Suggested test cases for each unit
- Any mocking requirements
## Self-Verification
Before completing your task:
- Confirm all tests pass after fixes
- Verify type checking passes: `npm run typecheck`
- Ensure no regressions were introduced
- Document any tests that were skipped and why
+310
View File
@@ -0,0 +1,310 @@
---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <category name> [--all] [--explicit] [--apply] [--limit=100]
description: Review and automatically fix issues with questions (text, targeting, intensity, props, duplicates)
---
# Question Fix Agent
Fix questions in category: $ARGUMENTS
## Task Overview
Analyze questions across all packs in a category and apply fixes for issues across 6 dimensions:
1. **Text Phrasing** - Improve wording, remove wishy-washy language
2. **Audience Targeting** - Fix couple/initiator gender targeting
3. **Intensity Levels** - Correct misassigned intensity ratings
4. **Required Props** - Identify missing physical props/accessories
5. **Inverse Linking** - Detect and link inverse question pairs using inverse_of column
6. **Deletions** - Remove true duplicates, unsafe, or broken questions
## Step 1: Parse Arguments
Extract from $ARGUMENTS:
- **Category identifier**: Category name (partial match) OR category ID (UUID)
- **--all**: Flag indicating the agent should work through all categories sequentially
- **--explicit**: Flag indicating this is an explicit/adult category (auto-detected from category.is_explicit if not specified)
- **--apply**: If set, apply fixes automatically after review. If not set, just show suggestions.
- **--limit**: Max questions to analyze per category (default: 100)
## Step 2: Fetch Data
Query the database to get the category and its questions:
```sql
-- Get category info
SELECT id, name
FROM categories
WHERE id::text = '<category_id>' OR name ILIKE '%<category_name>%'
LIMIT 1;
```
```sql
-- Get all packs in the category
SELECT p.id, p.name, p.is_explicit
FROM question_packs p
WHERE p.category_id = '<category_id>'
ORDER BY p.name;
```
```sql
-- Get questions for all packs in the category (include inverse_of for pair detection)
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.inverse_of, p.name as pack_name
FROM questions q
JOIN question_packs p ON q.pack_id = p.id
WHERE p.category_id = '<category_id>' AND q.deleted_at IS NULL
ORDER BY p.name, q.created_at
LIMIT <limit>;
```
```sql
-- Get existing props catalog for consistency
SELECT DISTINCT unnest(required_props) as prop
FROM questions
WHERE required_props IS NOT NULL
ORDER BY prop;
```
If **--all** flag is set, first list all categories:
```sql
SELECT id, name, is_explicit FROM categories ORDER BY name;
```
Then process each category sequentially.
## Step 3: Analyze Each Question
For each question, evaluate against these criteria:
### 3.1 Text Analysis
**Language Rules:**
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
- No time-specific words - NO "tonight", "now", "today", "right now"
- Keep concise - ideal length is 5-12 words
**Tone Rules (based on is_explicit):**
For EXPLICIT packs:
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name)
- NEVER sanitize "cum" to "come" - they have different meanings
For NON-EXPLICIT packs:
- CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms
- Flag explicit content for removal
**Partner Text Rules:**
- NULL for symmetric activities (both partners do the same thing together)
- FILLED for asymmetric activities (one does something to/for the other)
- text = what the INITIATOR does
- partner_text = what the RECEIVER experiences
- Make partner_text APPEALING - frame receiver's experience positively
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
- NOT: "Moan for your partner" (sounds forced)
### 3.2 Intensity Analysis
Check if current intensity matches the activity:
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
### 3.3 Targeting Analysis
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
**Initiator Targeting (target_user_genders):**
- DEFAULT: null (anyone can initiate)
- Only for asymmetric questions (those with partner_text)
- Set based on who does the action in "text" field:
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
- "Give your partner a massage" -> gender-neutral -> null
### 3.4 Props Analysis
**Rules:**
- Props are physical items/accessories, not body parts or acts
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
- Try to avoid generics such as "toy" (unless the activity can be done with any toy)
- Reuse existing prop names from the catalog when possible
- If no props are required, set to null
### 3.5 Inverse Linking Analysis
**Purpose:** Detect inverse question pairs and link them using the `inverse_of` database column.
**Detection Algorithm:**
1. For each asymmetric question (has partner_text), look for its inverse:
- Inverse exists if another question has: `text` = this question's `partner_text` AND `partner_text` = this question's `text`
2. Check if the `inverse_of` column is correctly set:
- One question in the pair should be the "primary" (inverse_of = NULL)
- The other should be the "inverse" (inverse_of = primary's UUID)
**Fixing Missing Links:**
- If inverse pair exists but neither has `inverse_of` set:
- Set the first one (by created_at) as primary (inverse_of = NULL)
- Set the second one's inverse_of = primary's UUID
- If inverse pair exists but both have `inverse_of = NULL`:
- Choose one as primary, link the other
- If a question has `inverse_of` pointing to a non-existent question:
- Find the actual inverse and fix the link, OR set to NULL if no inverse exists
**Important:**
- Only asymmetric questions (with partner_text) should have inverse pairs
- Symmetric questions (partner_text = NULL) should have inverse_of = NULL
- This fixes the database structure, NOT the question content
### 3.6 Deletion Analysis
**Deletion Categories:**
- `duplicate`: Same `text` AND same initiator as another question
- `redundant`: Minor variation with no added value
- `off-tone`: Violates explicit vs non-explicit rules
- `unsafe`: Coercion, minors, illegal acts, or consent violations
- `too-vague`: Not actionable or not a clear proposal
- `broken`: Grammar too broken to repair without inventing content
- `off-topic`: Not relevant to a couples intimacy app
**CRITICAL: Inverse Questions are NOT Duplicates**
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
```
Question A:
text: "Spank your partner"
partner_text: "Be spanked by your partner"
Question B (INVERSE - NOT A DUPLICATE):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
```
These are intentionally paired so both partners get asked about both roles. When reviewing:
- If you see two questions where one's `text` matches the other's `partner_text`, they are a VALID INVERSE PAIR
- Do NOT flag inverse pairs as duplicates or redundant
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`) with the SAME initiator role
**Rules:**
- Prefer edits over deletions when possible
- For true duplicates: delete the weaker/less specific version
- Do NOT delete for minor wording issues
## Step 4: Output Results
Present findings grouped by fix type:
### Summary
| Category | Issues Found | Will Fix |
|----------|--------------|----------|
| Text fixes | X | X |
| Intensity fixes | X | X |
| Targeting fixes | X | X |
| Props fixes | X | X |
| Inverse linking fixes | X | X |
| Deletions | X | X |
| No changes needed | X | - |
### Detailed Changes
For each question needing changes, show:
```
**Question ID:** <id>
**Current:** <text> / <partner_text or null>
**Current inverse_of:** <uuid or null>
**Issues:**
- Text: <issue description>
- Intensity: <current> -> <suggested> (<reason>)
- Targeting: <issue description>
- Props: <current> -> <suggested>
- Inverse Link: <missing/broken> - <inverse question id if found>
- DELETE: <category> - <reason>
**Fix:**
- Text: "<new text>"
- Partner Text: "<new partner_text>" or null
- Intensity: <new intensity>
- Couple Targets: <new allowed_couple_genders or null>
- Initiator Targets: <new target_user_genders or null>
- Required Props: <new required_props or null>
- inverse_of: <uuid or null>
```
## Step 5: Apply Fixes (if --apply flag)
If --apply flag is set, execute the SQL updates:
```sql
-- Text/Partner Text/Intensity fixes
UPDATE questions SET
text = '<new_text>',
partner_text = '<new_partner_text>',
intensity = <new_intensity>
WHERE id = '<question_id>';
-- Targeting fixes
UPDATE questions SET
allowed_couple_genders = ARRAY['...'] or NULL,
target_user_genders = ARRAY['...'] or NULL
WHERE id = '<question_id>';
-- Props fixes
UPDATE questions SET
required_props = ARRAY['...'] or NULL
WHERE id = '<question_id>';
-- Inverse linking fixes
-- Link inverse question to its primary
UPDATE questions SET inverse_of = '<primary_question_id>' WHERE id = '<inverse_question_id>';
-- Or clear a broken inverse_of link
UPDATE questions SET inverse_of = NULL WHERE id = '<question_id>';
-- Deletions (soft delete)
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
```
After applying, report:
- X questions updated
- X questions deleted
- Any errors encountered
If --apply is NOT set, output the SQL statements for manual review.
## Example Usage
```
/fix-questions "Kink" # Review all questions in "Kink" category
/fix-questions "Romantic Classics" --apply # Review and apply fixes to category
/fix-questions --all # Process all categories sequentially
/fix-questions --all --apply # Process and fix all categories
/fix-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
```
## Guidelines
- DO ask for clarification if category name matches multiple categories
- DO group similar issues together for batch fixes
- DO prioritize safety issues (delete unsafe content first)
- DO preserve the core intent of questions when suggesting text fixes
- DO verify inverse pairs exist before flagging duplicates
- DO detect and link inverse pairs using the inverse_of column
- DON'T change questions that are already well-formed
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
- DON'T create new prop names when existing ones match
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
- DON'T forget to set inverse_of for detected inverse pairs - this ensures accurate unique question counts
+283
View File
@@ -0,0 +1,283 @@
---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name> --category=<category> --intensity=<min-max> [--count=30] [--premium]
description: Generate a question pack for Sauci following established content guidelines
---
# Question Pack Generator
Generate a question pack: $ARGUMENTS
## Prerequisites
First, read the pack generation guidelines:
@PACK_GENERATION_AGENT.md
## Current State
- Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order
- Existing packs in target category: Will query once category is identified
- Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop
## Task
Generate a complete question pack following all Sauci content guidelines.
### Step 1: Parse Arguments
Extract from $ARGUMENTS:
- **Pack name**: Required - the name for this pack
- **Category**: Required - which category this belongs to (or "new" to create)
- **Intensity range**: Required - e.g., "2-3", "3-4", "4-5"
- **Question count**: Optional - default 30, can be 25-50
- **Premium**: Optional flag - if pack should be premium
### Step 2: Understand Context
1. If category exists, query existing packs to understand progression:
- What packs already exist?
- What intensity levels are covered?
- Where does this new pack fit in the progression?
2. Check if category is explicit:
```sql
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
```
3. If new category, establish the progression plan:
- What will the beginner pack cover?
- What will intermediate cover?
- What will advanced cover?
### Step 3: Generate Questions
Follow these rules strictly:
#### 3.1 Core Language Rules
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
- Questions are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
- No time-specific words - NO "tonight", "now", "today", "right now"
- Keep concise: 5-12 words ideal
- Modern language for 25-40 year olds
- Avoid cheesy/cliche phrasing (candlelit dinner, rose petals, bubble bath, Netflix and chill)
#### 3.2 Tone Rules (based on category's is_explicit flag)
**For EXPLICIT categories (is_explicit = true):**
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name), "Edge until they beg"
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
- NEVER sanitize "cum" to "come" - they have different meanings
**For NON-EXPLICIT categories (is_explicit = false):**
- CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms
- If intensity 3+ activities are needed, use suggestive but not explicit language
#### 3.3 Partner Text Rules
- **NULL** for symmetric activities (both partners do the same thing together)
- **Filled** for asymmetric activities (one does something to/for the other)
- `text` = what the INITIATOR does
- `partner_text` = what the RECEIVER experiences
**Partner Text Quality:**
- Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
- NOT: "Moan for your partner" (sounds forced, not natural)
- BAD: "Receive oral from your partner" (clinical)
- GOOD: "Let your partner pleasure you with their mouth" (enticing)
#### 3.4 Critical: Inverse Questions and Database Linking
For EVERY asymmetric question, create its inverse AND link them using the `inverse_of` column:
```
Original (PRIMARY - inverse_of = NULL):
id: <generated_uuid_1>
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_of: NULL
Inverse (SECONDARY - points to primary):
id: <generated_uuid_2>
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_of: <generated_uuid_1>
```
This ensures:
1. Both partners are asked about both roles
2. The database tracks which questions are inverses of each other
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
**Database Schema:**
- `inverse_of` column: UUID referencing the primary question's ID
- Primary questions have `inverse_of = NULL`
- Inverse questions have `inverse_of = <primary_question_id>`
#### 3.5 Intensity Guidelines
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
#### 3.6 Targeting Rules
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
- Options: `['male+male', 'female+male', 'female+female']` or subset
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis (blowjob, handjob)
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina (cunnilingus)
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone) -> null
**Initiator Targeting (target_user_genders):**
- DEFAULT: null (anyone can initiate)
- Only for asymmetric questions (those with partner_text)
- Set based on who does the action in "text" field:
- "Swallow your partner's cum" -> partner has penis -> initiator: `['female']` in M+F couples
- "Deep throat your partner" -> partner has penis -> `['female']` in M+F couples
- "Give your partner a massage" -> gender-neutral -> null
- When in doubt, use null
**Anatomical Consistency:**
- NEVER mix male-specific and female-specific acts as alternatives
- BAD: "Finger or give your partner a handjob" (mixed anatomy)
- GOOD: Pick one activity per question
#### 3.7 Required Props
Identify physical items/accessories needed:
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
- Props are NOT body parts or acts
- Use lowercase, short, singular names
- Reuse existing prop names from catalog when possible
- Set to null if no props are required
### Step 4: Review & Validate
Before outputting, verify each question against this checklist:
**Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns)
- [ ] Is a proposal, not an interview question
- [ ] No wishy-washy language
- [ ] No time-specific words
- [ ] 5-12 words (concise)
- [ ] No cheesy/cliche language
- [ ] Varied sentence structures
**Structure:**
- [ ] All asymmetric questions have inverses (these are NOT duplicates - they're required pairs)
- [ ] Partner text is appealing (not clinical)
- [ ] Partner text frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice)
**Accuracy:**
- [ ] Intensity level matches the activity
- [ ] Couple targeting is correct (or null for universal)
- [ ] Initiator targeting is correct for asymmetric questions
- [ ] Required props are identified
- [ ] Considers same-sex couples
**Consistency:**
- [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category
- [ ] Fits progression within category
### Step 5: Output
Provide the pack in SQL format ready for insertion:
```sql
-- First, create the pack (get category_id from earlier query)
INSERT INTO question_packs (name, description, category_id, is_premium)
VALUES (
'[Pack Name]',
'[Description]',
'[category_id]',
false
)
RETURNING id;
-- Then insert questions with inverse_of linking
-- For inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
-- Generate UUIDs for inverse pairs upfront
-- Pair 1: Primary and its inverse
WITH pair1_primary AS (
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL)
RETURNING id
)
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, id
FROM pair1_primary;
-- Symmetric questions (no inverse_of needed)
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
('[pack_id]', 'Symmetric question text', NULL, 2, NULL, NULL, NULL, NULL);
-- Or bulk insert with explicit UUIDs for pairs:
INSERT INTO questions (id, pack_id, text, partner_text, intensity, inverse_of) VALUES
-- Pair 1
('uuid-primary-1', '[pack_id]', 'Give your partner a massage', 'Receive a massage from your partner', 2, NULL),
('uuid-inverse-1', '[pack_id]', 'Receive a massage from your partner', 'Give your partner a massage', 2, 'uuid-primary-1'),
-- Pair 2
('uuid-primary-2', '[pack_id]', 'Blindfold your partner', 'Be blindfolded by your partner', 3, NULL),
('uuid-inverse-2', '[pack_id]', 'Be blindfolded by your partner', 'Blindfold your partner', 3, 'uuid-primary-2'),
-- Symmetric (no pair)
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL);
```
**Field notes:**
- `inverse_of`: NULL for primary/symmetric questions, UUID of primary question for inverses
- `allowed_couple_genders`: NULL for all couples, or array like `ARRAY['male+male', 'female+male']`
- `target_user_genders`: NULL for any initiator, or array like `ARRAY['female']`
- `required_props`: NULL if no props needed, or array like `ARRAY['blindfold', 'ice cubes']`
Also provide a summary table:
| Metric | Value |
|--------|-------|
| Total questions | X |
| Unique question concepts | X (total - inverse count) |
| Intensity 1 | X |
| Intensity 2 | X |
| Intensity 3 | X |
| Intensity 4 | X |
| Intensity 5 | X |
| Symmetric (null partner_text) | X |
| Asymmetric pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X |
| With initiator targeting | X |
| With required props | X |
## Example Usage
```
/generate-pack "Testing the Waters" --category="Public Thrills" --intensity=2-3 --count=30
/generate-pack "Kink Discovery" --category="The Kink Lab" --intensity=3-5 --count=50
/generate-pack "Staying Close" --category=new:"Long Distance" --intensity=1 --count=30
```
## Guidelines
- DO ask clarifying questions if the pack concept is unclear
- DO check existing packs in the category first
- DO create inverse questions for ALL asymmetric content AND link them with inverse_of
- DO vary sentence structure and openers
- DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
- DON'T use cheesy or cliche language
- DON'T assume heterosexual couples
- DON'T skip the inverse question requirement
- DON'T forget to set inverse_of - this is how we track unique question concepts
- DON'T use interview-style questions ("Would you like to...")
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
+87
View File
@@ -0,0 +1,87 @@
---
allowed-tools: Read, Write, Edit, Bash
argument-hint: [file-path] | [component-name]
description: Generate comprehensive test suite with unit, integration, and edge case coverage
---
# Generate Tests
Generate comprehensive test suite for: $ARGUMENTS
## Current Testing Setup
- Test framework: @package.json or @jest.config.js or @vitest.config.js (detect framework)
- Existing tests: !`find . -name "*.test.*" -o -name "*.spec.*" | head -5`
- Test coverage: !`npm run test:coverage 2>/dev/null || echo "No coverage script"`
- Target file: @$ARGUMENTS (if file path provided)
## Task
I'll analyze the target code and create complete test coverage including:
1. Unit tests for individual functions and methods
2. Integration tests for component interactions
3. Edge case and error handling tests
4. Mock implementations for external dependencies
5. Test utilities and helpers as needed
6. Performance and snapshot tests where appropriate
## Process
I'll follow these steps:
1. Analyze the target file/component structure
2. Identify all testable functions, methods, and behaviors
3. Examine existing test patterns in the project
4. Create test files following project naming conventions
5. Implement comprehensive test cases with proper setup/teardown
6. Add necessary mocks and test utilities
7. Verify test coverage and add missing test cases
## Test Types
### Unit Tests
- Individual function testing with various inputs
- Component rendering and prop handling
- State management and lifecycle methods
- Utility function edge cases and error conditions
### Integration Tests
- Component interaction testing
- API integration with mocked responses
- Service layer integration
- End-to-end user workflows
### Framework-Specific Tests
- **React**: Component testing with React Testing Library
- **Vue**: Component testing with Vue Test Utils
- **Angular**: Component and service testing with TestBed
- **Node.js**: API endpoint and middleware testing
## Testing Best Practices
### Test Structure
- Use descriptive test names that explain the behavior
- Follow AAA pattern (Arrange, Act, Assert)
- Group related tests with describe blocks
- Use proper setup and teardown for test isolation
### Mock Strategy
- Mock external dependencies and API calls
- Use factories for test data generation
- Implement proper cleanup for async operations
- Mock timers and dates for deterministic tests
### Coverage Goals
- Aim for 80%+ code coverage
- Focus on critical business logic paths
- Test both happy path and error scenarios
- Include boundary value testing
I'll adapt to your project's testing framework (Jest, Vitest, Cypress, etc.) and follow established patterns.
+283
View File
@@ -0,0 +1,283 @@
---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name-or-id> [--explicit] [--limit=100]
description: Review and fix questions for text, targeting, intensity, props, and duplicates
---
# Question Review Command
Review and validate questions: $ARGUMENTS
## Task Overview
Analyze questions for issues and suggest fixes across 6 dimensions:
1. **Text Phrasing** - Improve wording, remove wishy-washy language
2. **Audience Targeting** - Fix couple/initiator gender targeting
3. **Intensity Levels** - Correct misassigned intensity ratings
4. **Required Props** - Identify missing physical props/accessories
5. **Inverse Linking** - Detect and link inverse question pairs using inverse_of column
6. **Deletions** - Flag duplicates, redundant, or broken questions
## Step 1: Parse Arguments
Extract from $ARGUMENTS:
- **Pack identifier**: Pack name (partial match) OR pack ID (UUID)
- **--explicit**: Flag indicating this is an explicit/adult pack (default: false if not specified)
- **--limit**: Max questions to analyze (default: 100)
## Step 2: Fetch Questions
Query the database:
```sql
-- If pack name provided (include inverse_of for pair detection):
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.inverse_of
FROM questions q
JOIN question_packs p ON q.pack_id = p.id
WHERE p.name ILIKE '%<pack_name>%' AND q.deleted_at IS NULL
ORDER BY q.created_at
LIMIT <limit>;
-- If pack ID provided:
SELECT id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of
FROM questions
WHERE pack_id = '<pack_id>' AND deleted_at IS NULL
ORDER BY created_at
LIMIT <limit>;
```
Also fetch existing props for consistency:
```sql
SELECT DISTINCT unnest(required_props) as prop
FROM questions
WHERE required_props IS NOT NULL
ORDER BY prop;
```
## Step 3: Determine Content Type
Check if the pack is explicit:
```sql
SELECT p.name, c.is_explicit
FROM question_packs p
JOIN categories c ON p.category_id = c.id
WHERE p.id = '<pack_id>' OR p.name ILIKE '%<pack_name>%'
LIMIT 1;
```
Use this to set the content type rules below.
## Step 4: Analyze Questions
For each question, evaluate against these criteria:
### 4.1 Text Analysis
**Language Rules:**
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
- No time-specific words - NO "tonight", "now", "today", "right now"
- Keep concise - ideal length is 5-12 words
**Tone Rules (based on explicit flag):**
For EXPLICIT packs:
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name)
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
- NEVER sanitize "cum" to "come" - they have different meanings
For NON-EXPLICIT packs:
- CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms
- Flag explicit content for removal
**Partner Text Rules:**
- NULL for symmetric activities (both partners do the same thing together)
- FILLED for asymmetric activities (one does something to/for the other)
- text = what the INITIATOR does
- partner_text = what the RECEIVER experiences
- Make partner_text APPEALING - frame receiver's experience positively
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
- NOT: "Moan for your partner" (sounds forced)
### 4.2 Intensity Analysis
Check if current intensity matches the activity:
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
### 4.3 Targeting Analysis
**Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
**Initiator Targeting (target_user_genders):**
- DEFAULT: null (anyone can initiate)
- Only for asymmetric questions (those with partner_text)
- Set based on who does the action in "text" field:
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
- "Give your partner a massage" -> gender-neutral -> null
### 4.4 Props Analysis
**Rules:**
- Props are physical items/accessories, not body parts or acts
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
- Reuse existing prop names from the catalog when possible
- If no props are required, set to null
### 4.5 Inverse Linking Analysis
**Purpose:** Detect inverse question pairs and link them using the `inverse_of` database column.
**Detection:**
1. For each asymmetric question (has partner_text), look for its inverse:
- Inverse exists if another question has: `text` = this question's `partner_text` AND `partner_text` = this question's `text`
2. Check if the `inverse_of` column is correctly set
**Fixes:**
- If pair exists but not linked: set inverse_of on the second question to point to the first
- Symmetric questions should have inverse_of = NULL
### 4.6 Deletion Analysis
**Deletion Categories:**
- `duplicate`: Same core action as another question
- `redundant`: Minor variation with no added value
- `off-tone`: Violates explicit vs non-explicit rules
- `unsafe`: Coercion, minors, illegal acts, or consent violations
- `too-vague`: Not actionable or not a clear proposal
- `broken`: Grammar too broken to repair without inventing content
- `off-topic`: Not relevant to a couples intimacy app
**Rules:**
- Prefer edits over deletions when possible
- For duplicates: delete the weaker/less specific version
- Do NOT delete for minor wording issues
**CRITICAL: Inverse Questions are NOT Duplicates**
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
```
Question A:
text: "Spank your partner"
partner_text: "Be spanked by your partner"
Question B (INVERSE - NOT A DUPLICATE):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
```
These are intentionally paired so both partners get asked about both roles. When reviewing:
- If you see two questions where one's `text` matches the other's `partner_text`, they are a valid inverse pair
- Do NOT flag inverse pairs as duplicates or redundant
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`)
## Step 5: Output Results
Present findings in a structured format:
### Summary
| Category | Count |
|----------|-------|
| Text fixes | X |
| Intensity fixes | X |
| Targeting fixes | X |
| Props fixes | X |
| Inverse linking fixes | X |
| Deletions suggested | X |
| No changes needed | X |
### Suggested Changes
For each question needing changes:
```
**Question ID:** <id>
**Current Text:** <text>
**Current Partner Text:** <partner_text or null>
**Current Intensity:** <intensity>
**Current inverse_of:** <uuid or null>
Issues Found:
- [ ] Text: <description of issue>
- [ ] Intensity: <current> -> <suggested> (<reason>)
- [ ] Targeting: <description>
- [ ] Props: <current> -> <suggested>
- [ ] Inverse Link: <missing/broken> - <inverse question id if found>
- [ ] DELETE: <category> - <reason>
Suggested Fix:
- Text: "<new text>"
- Partner Text: "<new partner_text>" or null
- Intensity: <new intensity>
- Couple Targets: <new allowed_couple_genders or null>
- Initiator Targets: <new target_user_genders or null>
- Required Props: <new required_props or null>
- inverse_of: <uuid or null>
```
### SQL to Apply Fixes
After user approval, provide UPDATE statements:
```sql
-- Text/Partner Text/Intensity fixes
UPDATE questions SET
text = '<new_text>',
partner_text = '<new_partner_text>',
intensity = <new_intensity>
WHERE id = '<question_id>';
-- Targeting fixes
UPDATE questions SET
allowed_couple_genders = '<new_targets>',
target_user_genders = '<new_initiator>'
WHERE id = '<question_id>';
-- Props fixes
UPDATE questions SET
required_props = ARRAY[<new_props>]
WHERE id = '<question_id>';
-- Inverse linking fixes
UPDATE questions SET inverse_of = '<primary_question_id>' WHERE id = '<inverse_question_id>';
-- Deletions (soft delete)
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
```
## Example Usage
```
/review-questions "Foreplay Favorites"
/review-questions "Kink Discovery" --explicit
/review-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
```
## Guidelines
- DO ask for clarification if pack name matches multiple packs
- DO group similar issues together for batch fixes
- DO prioritize safety issues (delete unsafe content first)
- DO preserve the core intent of questions when suggesting text fixes
- DO detect and link inverse pairs using the inverse_of column
- DON'T change questions that are already well-formed
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
- DON'T create new prop names when existing ones match
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
- DON'T forget to set inverse_of for detected inverse pairs
+106
View File
@@ -0,0 +1,106 @@
---
allowed-tools: Read, Write, Edit, Bash
argument-hint: [doc-type] | --implementation | --api | --architecture | --sync | --validate
description: Systematically update project documentation with implementation status, API changes, and synchronized content
---
# Documentation Update & Synchronization
Update project documentation systematically: $ARGUMENTS
## Current Documentation State
- Documentation structure: !`find . -name "*.md" | head -10`
- Specs directory: @specs/ (if exists)
- Implementation status: !`grep -r "✅\|❌\|⚠️" docs/ specs/ 2>/dev/null | wc -l` status indicators
- Recent changes: !`git log --oneline --since="1 week ago" -- "*.md" | head -5`
- Project progress: @CLAUDE.md or @README.md (if exists)
## Task
## Documentation Analysis
1. Review current documentation status:
- Check `specs/implementation_status.md` for overall project status
- Review implemented phase document (`specs/phase{N}_implementation_plan.md`)
- Review `specs/flutter_structurizr_implementation_spec.md` and `specs/flutter_structurizr_implementation_spec_updated.md`
- Review `specs/testing_plan.md` to ensure it is current given recent test passes, failures, and changes
- Examine `CLAUDE.md` and `README.md` for project-wide documentation
- Check for and document any new lessons learned or best practices in CLAUDE.md
2. Analyze implementation and testing results:
- Review what was implemented in the last phase
- Review testing results and coverage
- Identify new best practices discovered during implementation
- Note any implementation challenges and solutions
- Cross-reference updated documentation with recent implementation and test results to ensure accuracy
## Documentation Updates
1. Update phase implementation document:
- Mark completed tasks with ✅ status
- Update implementation percentages
- Add detailed notes on implementation approach
- Document any deviations from original plan with justification
- Add new sections if needed (lessons learned, best practices)
- Document specific implementation details for complex components
- Include a summary of any new troubleshooting tips or workflow improvements discovered during the phase
2. Update implementation status document:
- Update phase completion percentages
- Add or update implementation status for components
- Add notes on implementation approach and decisions
- Document best practices discovered during implementation
- Note any challenges overcome and solutions implemented
3. Update implementation specification documents:
- Mark completed items with ✅ or strikethrough but preserve original requirements
- Add notes on implementation details where appropriate
- Add references to implemented files and classes
- Update any implementation guidance based on experience
4. Update CLAUDE.md and README.md if necessary:
- Add new best practices
- Update project status
- Add new implementation guidance
- Document known issues or limitations
- Update usage examples to include new functionality
5. Document new testing procedures:
- Add details on test files created
- Include test running instructions
- Document test coverage
- Explain testing approach for complex components
## Documentation Formatting and Structure
1. Maintain consistent documentation style:
- Use clear headings and sections
- Include code examples where helpful
- Use status indicators (✅, ⚠️, ❌) consistently
- Maintain proper Markdown formatting
2. Ensure documentation completeness:
- Cover all implemented features
- Include usage examples
- Document API changes or additions
- Include troubleshooting guidance for common issues
## Guidelines
- DO NOT CREATE new specification files
- UPDATE existing files in the `specs/` directory
- Maintain consistent documentation style
- Include practical examples where appropriate
- Cross-reference related documentation sections
- Document best practices and lessons learned
- Provide clear status updates on project progress
- Update numerical completion percentages
- Ensure documentation reflects actual implementation
Provide a summary of documentation updates after completion, including:
1. Files updated
2. Major changes to documentation
3. Updated completion percentages
4. New best practices documented
5. Status of the overall project after this phase
+15 -2
View File
@@ -1,7 +1,20 @@
{
"permissions": {
"allow": [
"Bash(wc:*)"
"Bash(wc:*)",
"Bash(find:*)",
"Bash(for f in /Users/jjbrunton/Projects/Sauci/apps/supabase/migrations/*.sql)",
"Bash(do grep -q \"deleted_at\" \"$f\")",
"Bash(echo:*)",
"Bash(done)",
"Bash(claude --version:*)",
"Bash(strace:*)",
"Bash(dtruss:*)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sauci-prod",
"sauci-non-prod"
]
}
}
+1
View File
@@ -3,6 +3,7 @@ node_modules/
.pnp
.pnp.js
tmp/
# Build outputs
dist/
build/
+458
View File
@@ -0,0 +1,458 @@
# Pack Generation Agent
System prompt and guidelines for generating question packs for Sauci.
## Agent Role
You are a content generation specialist for Sauci, a couples intimacy app where partners swipe on activity proposals. Your job is to create question packs that help couples discover shared interests and activities.
## Core Principles
### 1. Questions are PROPOSALS, not interview questions
- GOOD: "Give your partner a massage"
- BAD: "Would you like to give a massage?"
- BAD: "Have you ever given a massage?"
### 2. Always use "your partner" - never gendered pronouns
- GOOD: "Go down on your partner"
- BAD: "Go down on him/her"
- BAD: "Give your partner a blowjob" (gender-specific act without targeting)
### 3. No time-specific language
- BAD: "tonight", "now", "today", "right now"
- Activities should be timeless proposals
### 4. Keep it concise
- Ideal length: 5-15 words
- No unnecessary filler
### 5. Target audience: 25-40 year old couples
- Modern, realistic language
- Avoid cheesy, cliche, or Hallmark-card phrasing
- Think about what real couples actually do and say
---
## Question Structure
Each question has:
- `text` - What Partner A sees
- `partner_text` - What Partner B sees (can be NULL)
- `intensity` - 1-5 scale
- `allowed_couple_genders` - Which couple types see this (NULL = all)
- `target_user_genders` - Which gender initiates (NULL = any)
- `required_props` - Physical items needed (NULL = none)
- `inverse_of` - UUID of primary question if this is an inverse (NULL for primary/symmetric)
### When to use partner_text
**Use NULL** for symmetric activities where both partners do the same thing:
```
text: "Cook dinner together"
partner_text: NULL
```
**Use partner_text** for asymmetric activities where roles differ:
```
text: "Give your partner a massage"
partner_text: "Receive a massage from your partner"
```
### Critical: Inverse Questions and Database Linking
For any asymmetric question, you MUST create TWO questions AND link them using the `inverse_of` column:
**Question 1 (PRIMARY - inverse_of = NULL):**
```
id: uuid-1
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_of: NULL
```
**Question 2 (INVERSE - inverse_of = primary's UUID):**
```
id: uuid-2
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_of: uuid-1
```
This ensures:
- Partner A gets asked if they want to give AND receive
- Partner B gets asked if they want to give AND receive
- All four combinations can be discovered through matching
- The database tracks which questions are pairs (for accurate unique counts)
**IMPORTANT:**
- Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates.
- The `inverse_of` column links the pair: the inverse question points to the primary question's UUID
- This allows the app to display accurate unique question counts (100 concepts, not 200 questions)
---
## Targeting Rules
### Couple Targeting (allowed_couple_genders)
Controls which couple types see the question. Options: `male+male`, `female+male`, `female+female`
**DEFAULT: NULL** (all couples) unless explicit anatomical requirement.
| Activity Type | Targeting | Reason |
|--------------|-----------|--------|
| Gender-neutral | NULL | Anyone can do it |
| Requires penis | `['male+male', 'female+male']` | Exclude F+F |
| Requires vagina | `['female+male', 'female+female']` | Exclude M+M |
| Sex toys | NULL | Toys work for everyone |
Examples:
- "Give your partner a massage" → NULL
- "Give your partner a blowjob" → `['male+male', 'female+male']`
- "Go down on your partner" → NULL (oral is possible for all)
- "Use a vibrator on your partner" → NULL
### Initiator Targeting (target_user_genders)
For asymmetric questions, controls who does the action in `text`.
**DEFAULT: NULL** (anyone can initiate).
Only set when the `text` field requires specific anatomy:
- "Swallow your partner's cum" → partner has penis → `['female']` in M+F
- "Deep throat your partner" → partner has penis → `['female']` in M+F
- "Let your partner cum inside you" → receiver has vagina → `['female']`
When in doubt, use NULL.
### Anatomical Consistency
**NEVER mix male-specific and female-specific acts in the same question.**
- BAD: "Finger or give your partner a handjob"
- GOOD: Create separate questions for each
---
## Required Props
Identify physical items/accessories needed for the activity.
**Rules:**
- Props are physical items, NOT body parts or acts
- Use lowercase, short, singular names
- Reuse existing prop names when possible
- Set to NULL if no props needed
**Common props:**
- blindfold, handcuffs, rope, massage oil
- remote vibrator, butt plug, dildo, cock ring
- ice cubes, feather, candle (for wax play)
- mirror, camera/phone (for recording)
---
## Intensity Levels
### Level 1 - GENTLE
Pure emotional connection, non-sexual bonding
- Examples: Cook together, watch movies, take walks, game nights
- No sexual content whatsoever
### Level 2 - WARM
Romantic atmosphere, affectionate touch
- Examples: Slow dancing, candlelit dinners, extended kissing, cuddling
- Romantic but not explicit
### Level 3 - PLAYFUL
Light sexual exploration, sensual discovery
- Examples: Mutual masturbation, oral sex, light roleplay, basic toys
- Sexual but not intense
### Level 4 - STEAMY
Explicit sexual activities, moderate adventure
- Examples: Sex positions, light bondage, anal play, recording yourselves
- Full sexual content, some kink
### Level 5 - INTENSE
Advanced/BDSM/extreme exploration
- Examples: Heavy impact play, advanced bondage, power exchange, edge play
- Intense kink, requires trust and experience
---
## Pack Progression Structure
Most categories should have 3-5 packs that progress in intensity:
### Example: Toy Time
1. **Starting Out** (intensity 3-4) - Basic vibrators, blindfolds, simple toys
2. **Leveling Up Together** (intensity 4, some 5) - Remote toys, edging, combinations
3. **The Deep End** (intensity 4-5) - Machines, bondage furniture, advanced play
### Example: Long Distance
1. **Staying Close** (intensity 1) - FaceTime, voice notes, gaming together
2. **Missing You** (intensity 2) - Flirty texts, missing each other, anticipation
3. **Private Line** (intensity 3-4) - Sexting, phone sex, explicit photos
4. **Total Control** (intensity 4-5) - Remote toys, edging, power dynamics
### Example: Public Thrills
1. **Testing the Waters** (intensity 2-3) - Whispers, secret touches, stolen kisses
2. **Pushing Limits** (intensity 3-4) - Making out in risky spots, dressing rooms
3. **All Eyes On Us** (intensity 4-5) - Sex in public places, exhibitionism
---
## Category Types
### Non-Sexual Categories (intensity 1-2)
- Social Life, Quality Time, Kitchen Chemistry
- Focus on bonding, activities, preferences
- partner_text usually NULL (symmetric activities)
### Sexual Categories (intensity 3-5)
- Toy Time, Public Thrills, Kink Lab, Bedroom Adventures
- Mix of symmetric and asymmetric questions
- Always create inverse questions for asymmetric content
### Mixed Categories
- Long Distance, Romance & Sensuality
- Start non-sexual, progress to sexual
- Clear intensity progression across packs
---
## Content Guidelines
### DO:
- Use realistic, modern language
- Consider same-sex couples (avoid anatomy-specific unless tagged)
- Create variety in sentence openers
- Think about what 25-40 year olds actually do
- Include multiple perspectives per kink/activity
- Make activities feel achievable and fun
### DON'T:
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
- Assume heterosexual couples by default
- Create one-sided asymmetric questions (always make the inverse)
- Use time-specific language
- Be preachy or educational in tone
- Include judgment about activities
- Treat inverse pairs as duplicates (one's `text` = other's `partner_text` is INTENTIONAL)
### Avoid Cliches:
- Candlelit dinner (unless specific twist)
- Rose petals
- Bubble bath
- "Netflix and chill"
- "Make love"
- Sunset walks on the beach
---
## Generation Process
When asked to create a pack:
1. **Understand the category and position**
- What category does this belong to?
- Where in the progression is this pack? (beginner/intermediate/advanced)
- What intensity range?
2. **Brainstorm themes and activities**
- What specific activities fit this pack?
- What makes this pack different from adjacent packs?
- What progression from previous pack?
3. **Generate questions**
- Mix of symmetric (null partner_text) and asymmetric questions
- For EVERY asymmetric question, immediately create its inverse
- Vary sentence structure and openers
- Target ~30-50 questions per pack
4. **Review for balance**
- Check intensity distribution
- Ensure all perspectives covered
- Remove duplicates or too-similar questions
- Verify no cliches or cheesy language
5. **Format output**
- Provide as SQL INSERT or structured format
- Include pack name, description, category
- Note premium status if applicable
---
## Output Format
When generating packs, output in this format (with inverse_of linking for pairs):
```sql
-- Pack: [Pack Name]
-- Category: [Category Name]
-- Description: [Pack description]
-- Intensity range: [X-Y]
-- For inverse pairs, insert primary first (inverse_of = NULL), then inverse pointing to primary
INSERT INTO questions (id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
-- Pair 1
('uuid-primary-1', '[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL, NULL, NULL, NULL),
('uuid-inverse-1', '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, NULL, NULL, NULL, 'uuid-primary-1'),
-- Pair 2 (with targeting)
('uuid-primary-2', '[pack_id]', 'Give your partner a blowjob', 'Receive a blowjob from your partner', 3, ARRAY['male+male', 'female+male'], NULL, NULL, NULL),
('uuid-inverse-2', '[pack_id]', 'Receive a blowjob from your partner', 'Give your partner a blowjob', 3, ARRAY['male+male', 'female+male'], NULL, NULL, 'uuid-primary-2'),
-- Symmetric (no inverse needed)
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL, NULL, NULL, NULL);
```
Or as a table (include inverse_of column):
| ID | Text | Partner Text | Intensity | Couple Targets | Initiator | Props | inverse_of |
|----|------|--------------|-----------|----------------|-----------|-------|------------|
| uuid-1 | Spank your partner | Be spanked | 3 | NULL | NULL | NULL | NULL |
| uuid-2 | Be spanked by partner | Spank your partner | 3 | NULL | NULL | NULL | uuid-1 |
| uuid-3 | Cook together | null | 1 | NULL | NULL | NULL | NULL |
---
## Examples of Good Questions
### Non-Sexual (Intensity 1)
```
text: "Cook the same recipe together over video call"
partner_text: NULL
intensity: 1
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
```
### Romantic (Intensity 2)
```
text: "Send a tipsy voice note telling them you miss them"
partner_text: NULL
intensity: 2
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
```
### Playful (Intensity 3) - With Props and Inverse Linking
```
id: uuid-blindfold-primary
text: "Blindfold your partner and tease them with different sensations"
partner_text: "Be blindfolded while your partner teases you"
intensity: 3
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: ["blindfold"]
inverse_of: NULL
```
AND its inverse (linked via inverse_of):
```
id: uuid-blindfold-inverse
text: "Be blindfolded while your partner teases you"
partner_text: "Blindfold your partner and tease them with different sensations"
intensity: 3
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: ["blindfold"]
inverse_of: uuid-blindfold-primary
```
### Steamy (Intensity 4) - With Props and Targeting
```
text: "Control your partner's remote vibrator while out at dinner"
partner_text: "Wear a remote vibrator at dinner while your partner controls it"
intensity: 4
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: ["remote vibrator"]
```
AND its inverse:
```
text: "Wear a remote vibrator at dinner while your partner controls it"
partner_text: "Control your partner's remote vibrator while out at dinner"
intensity: 4
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: ["remote vibrator"]
```
### With Couple Targeting (Intensity 3)
```
text: "Give your partner a blowjob in a risky location"
partner_text: "Receive a blowjob from your partner in a risky location"
intensity: 3
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
target_user_genders: NULL
required_props: NULL
```
### With Initiator Targeting (Intensity 4)
```
text: "Swallow your partner's cum"
partner_text: "Have your partner swallow your cum"
intensity: 4
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
target_user_genders: ["female"] -- in M+F, female receives
required_props: NULL
```
### Intense (Intensity 5)
```
text: "Edge your partner repeatedly, denying release until you decide"
partner_text: "Be edged and denied until your partner allows you to finish"
intensity: 5
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
```
AND its inverse:
```
text: "Be edged and denied until your partner allows you to finish"
partner_text: "Edge your partner repeatedly, denying release until you decide"
intensity: 5
allowed_couple_genders: NULL
target_user_genders: NULL
required_props: NULL
```
---
## Checklist Before Submitting
**Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns like him/her/he/she)
- [ ] Questions are proposals, not interview questions
- [ ] No wishy-washy language ("Would you...", "Have you ever...")
- [ ] No time-specific language (tonight, now, today)
- [ ] 5-12 words per question (concise)
- [ ] No cheesy or cliche language
- [ ] Mix of sentence structures
- [ ] Appropriate for 25-40 year old couples
**Structure:**
- [ ] All asymmetric questions have their inverse created (these are NOT duplicates - they're required pairs)
- [ ] All inverse pairs are linked via `inverse_of` column (inverse points to primary's UUID)
- [ ] Partner text is appealing (not clinical)
- [ ] Partner text frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice)
**Database Linking:**
- [ ] Primary questions have `inverse_of = NULL`
- [ ] Inverse questions have `inverse_of = <primary_question_uuid>`
- [ ] Symmetric questions have `inverse_of = NULL`
**Accuracy:**
- [ ] Intensity levels match the activities
- [ ] Couple targeting is correct (NULL unless anatomy-specific)
- [ ] Initiator targeting is correct for asymmetric questions
- [ ] Required props are identified where needed
- [ ] Consider same-sex couples
**Consistency:**
- [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category
- [ ] Pack fits its position in the progression
+9 -4
View File
@@ -1,15 +1,20 @@
FROM node:20-alpine AS builder
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy everything first (workspaces need full structure)
COPY . .
# Install all dependencies
RUN npm install
RUN pnpm install --no-frozen-lockfile
# Build shared package first
RUN npx turbo run build --filter=@sauci/shared
RUN pnpm exec turbo run build --filter=@sauci/shared
# Build admin app with env vars
ARG VITE_SUPABASE_URL
@@ -36,7 +41,7 @@ ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED
ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL
ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL
RUN npx turbo run build --filter=@sauci/admin
RUN pnpm exec turbo run build --filter=@sauci/admin
FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
+19 -4
View File
@@ -1,20 +1,35 @@
FROM node:20-alpine
FROM node:20-bookworm-slim
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy root package files and workspace configs
COPY package*.json ./
COPY pnpm-workspace.yaml ./
COPY turbo.json ./
# Copy all workspace package.json files
COPY apps/admin/package.json ./apps/admin/
COPY apps/mobile/package.json ./apps/mobile/
COPY apps/supabase/package.json ./apps/supabase/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm install
# Install all dependencies at root level (workspaces)
RUN pnpm install --no-frozen-lockfile
# Copy all source code
COPY . .
RUN npx turbo run build --filter=@sauci/shared
# Build shared package
RUN pnpm exec turbo run build --filter=@sauci/shared
WORKDIR /app/apps/admin
EXPOSE 3001
CMD ["npm", "run", "dev"]
CMD ["pnpm", "run", "dev"]
+2
View File
@@ -23,4 +23,6 @@ services:
- ./vite.config.ts:/app/apps/admin/vite.config.ts
- ./tsconfig.json:/app/apps/admin/tsconfig.json
- ../../packages/shared/src:/app/packages/shared/src
# Preserve node_modules from container (don't override with host)
- /app/node_modules
restart: unless-stopped
+4 -2
View File
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-avatar": "^1.0.4",
@@ -22,7 +25,7 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@sauci/shared": "*",
"@sauci/shared": "workspace:*",
"@supabase/supabase-js": "^2.39.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -34,7 +37,6 @@
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.21.0",
"recharts": "^3.6.0",
"sonner": "^1.3.1",
"tailwind-merge": "^2.2.0"
+36
View File
@@ -7,6 +7,10 @@ import { DashboardPage } from '@/pages/DashboardPage';
import { CategoriesPage } from '@/pages/content/CategoriesPage';
import { PacksPage } from '@/pages/content/PacksPage';
import { QuestionsPage } from '@/pages/content/QuestionsPage';
import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
import { TagsPage } from '@/pages/content/TagsPage';
import { DarePacksPage } from '@/pages/content/DarePacksPage';
import { DaresPage } from '@/pages/content/DaresPage';
import { UsersPage } from '@/pages/users/UsersPage';
import { UserDetailPage } from '@/pages/users/UserDetailPage';
import { MatchChatPage } from '@/pages/users/MatchChatPage';
@@ -64,6 +68,38 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/question-analytics"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
<QuestionAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="/tags"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
<TagsPage />
</ProtectedRoute>
}
/>
<Route
path="/dare-packs"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
<DarePacksPage />
</ProtectedRoute>
}
/>
<Route
path="/dare-packs/:packId/dares"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
<DaresPage />
</ProtectedRoute>
}
/>
{/* User management */}
<Route
@@ -53,10 +53,10 @@ export function GenerationForm({
</div>
)}
{/* Wildness Level Selector (questions only) */}
{/* Intimacy Level Selector (questions only) */}
{type === 'questions' && (
<div className="space-y-2">
<Label>Wildness Level</Label>
<Label>Intimacy Level</Label>
<div className="flex flex-wrap gap-2">
{TONE_LEVELS.map((t) => (
<Button
@@ -77,7 +77,7 @@ export function useAiGeneration(
// Config state
const [config, setConfig] = useState<GenerationConfig>({
count: 10,
tone: context?.isExplicit ? 5 : 3,
tone: context?.isExplicit ? 5 : 2,
crudeLang: false,
inspiration: '',
});
@@ -123,7 +123,7 @@ export function useAiGeneration(
if (type === 'questions') {
setConfig(prev => ({
...prev,
tone: context?.isExplicit ? 5 : 3,
tone: context?.isExplicit ? 5 : 2,
}));
}
}, [context?.isExplicit, type]);
@@ -1,4 +1,3 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,6 +13,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Loader2, X } from 'lucide-react';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
@@ -26,6 +32,11 @@ interface Topic {
name: string;
}
interface Category {
id: string;
name: string;
}
export interface PackFormData {
name: string;
description: string;
@@ -33,6 +44,7 @@ export interface PackFormData {
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
category_id: string;
}
export interface PackFormDialogProps {
@@ -43,6 +55,7 @@ export interface PackFormDialogProps {
onSave: () => void;
saving: boolean;
isEditing: boolean;
categories: Category[];
allTopics: Topic[];
selectedTopicIds: Set<string>;
onTopicsChange: (topicIds: Set<string>) => void;
@@ -60,6 +73,7 @@ export function PackFormDialog({
onSave,
saving,
isEditing,
categories,
allTopics,
selectedTopicIds,
onTopicsChange,
@@ -141,6 +155,27 @@ export function PackFormDialog({
/>
</div>
{/* Category */}
<div className="space-y-2">
<Label>Category (optional)</Label>
<Select
value={formData.category_id || '__none__'}
onValueChange={(value) => setField('category_id', value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No category</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Premium Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@@ -11,8 +11,18 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck } from 'lucide-react';
import { analyzeQuestionText, analyzeQuestionTargets, TextAnalysis, TargetAnalysis } from '@/lib/openai';
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck, Gauge, Trash2, Package } from 'lucide-react';
import {
analyzeQuestionText,
analyzeQuestionTargets,
analyzeQuestionDeletions,
analyzeQuestionProps,
TextAnalysis,
TargetAnalysis,
DeletionAnalysis,
PropsAnalysis,
} from '@/lib/openai';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner';
@@ -23,6 +33,7 @@ interface Question {
intensity: number;
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
required_props?: string[] | null;
}
interface ReviewQuestionsDialogProps {
@@ -38,13 +49,19 @@ interface CombinedSuggestion {
question: Question;
textSuggestion?: TextAnalysis;
targetSuggestion?: TargetAnalysis;
propsSuggestion?: PropsAnalysis;
deleteSuggestion?: DeletionAnalysis;
}
export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: ReviewQuestionsDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [suggestions, setSuggestions] = useState<CombinedSuggestion[]>([]);
const [existingProps, setExistingProps] = useState<string[]>([]);
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(new Set());
const [selectedPropsIds, setSelectedPropsIds] = useState<Set<string>>(new Set());
const [selectedIntensityIds, setSelectedIntensityIds] = useState<Set<string>>(new Set());
const [selectedDeleteIds, setSelectedDeleteIds] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
@@ -71,14 +88,55 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
return sortedA.every((val, i) => val === sortedB[i]);
};
const loadExistingProps = async (): Promise<string[]> => {
if (existingProps.length > 0) return existingProps;
try {
const { data, error } = await supabase
.from('questions')
.select('required_props')
.not('required_props', 'is', null)
.limit(5000);
if (error) throw error;
const next = new Set<string>();
data?.forEach(row => {
row.required_props?.forEach((prop: string) => next.add(prop));
});
const list = Array.from(next).sort((a, b) => a.localeCompare(b));
setExistingProps(list);
return list;
} catch (error) {
console.error('Failed to load existing props:', error);
return [];
}
};
const handleAnalyze = async () => {
setAnalyzing(true);
try {
// Run both analyses in parallel
const [textResults, targetResults] = await Promise.all([
analyzeQuestionText(questions, isExplicit),
analyzeQuestionTargets(questions)
]);
const propsCatalog = await loadExistingProps();
// Run analyses in parallel (pass intensity for text analysis)
const [textResults, targetResults, deleteResults, propsResults] = await Promise.all([
analyzeQuestionText(
questions.map(q => ({ ...q, intensity: q.intensity })),
isExplicit
),
analyzeQuestionTargets(questions),
analyzeQuestionDeletions(questions, isExplicit),
analyzeQuestionProps(
questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
required_props: q.required_props || null,
})),
propsCatalog
),
]) as [TextAnalysis[], TargetAnalysis[], DeletionAnalysis[], PropsAnalysis[]];
// Filter target results to only meaningful changes
const meaningfulTargetResults = targetResults.filter(suggestion => {
@@ -98,26 +156,35 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
return coupleChanged || initiatorChanged;
});
// Combine results by question ID
const questionIds = new Set([
...textResults.map(t => t.id),
...meaningfulTargetResults.map(t => t.id)
]);
const meaningfulPropsResults = propsResults.filter(suggestion => {
const question = questions.find(q => q.id === suggestion.id);
if (!question) return false;
const combined: CombinedSuggestion[] = Array.from(questionIds).map(id => {
const question = questions.find(q => q.id === id)!;
return {
questionId: id,
question,
textSuggestion: textResults.find(t => t.id === id),
targetSuggestion: meaningfulTargetResults.find(t => t.id === id),
};
return !arraysEqual(
question.required_props || null,
suggestion.suggested_required_props || null
);
});
const combined: CombinedSuggestion[] = questions.map(question => ({
questionId: question.id,
question,
textSuggestion: textResults.find(t => t.id === question.id),
targetSuggestion: meaningfulTargetResults.find(t => t.id === question.id),
propsSuggestion: meaningfulPropsResults.find(p => p.id === question.id),
deleteSuggestion: deleteResults.find(d => d.id === question.id),
}));
setSuggestions(combined);
// Default select all
setSelectedTextIds(new Set(textResults.map(r => r.id)));
setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id)));
setSelectedPropsIds(new Set(meaningfulPropsResults.map(r => r.id)));
setSelectedDeleteIds(new Set(deleteResults.map(r => r.id)));
// Select intensity suggestions by default
setSelectedIntensityIds(new Set(
textResults.filter(r => r.suggested_intensity != null).map(r => r.id)
));
setStep('review');
} catch (error) {
console.error(error);
@@ -131,13 +198,19 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
setApplying(true);
try {
const updates: { id: string; data: Record<string, unknown> }[] = [];
const deleteIds = Array.from(selectedDeleteIds);
// Build update objects combining text and target changes
// Build update objects combining text, target, and intensity changes
suggestions.forEach(suggestion => {
if (selectedDeleteIds.has(suggestion.questionId)) return;
const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion;
const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion;
const propsSelected = selectedPropsIds.has(suggestion.questionId) && suggestion.propsSuggestion;
const intensitySelected = selectedIntensityIds.has(suggestion.questionId) &&
suggestion.textSuggestion?.suggested_intensity != null;
if (textSelected || targetSelected) {
if (textSelected || targetSelected || propsSelected || intensitySelected) {
const updateData: Record<string, unknown> = {};
if (textSelected && suggestion.textSuggestion) {
@@ -156,23 +229,49 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
: null;
}
if (propsSelected && suggestion.propsSuggestion) {
updateData.required_props = suggestion.propsSuggestion.suggested_required_props &&
suggestion.propsSuggestion.suggested_required_props.length > 0
? suggestion.propsSuggestion.suggested_required_props
: null;
}
if (intensitySelected && suggestion.textSuggestion?.suggested_intensity != null) {
updateData.intensity = suggestion.textSuggestion.suggested_intensity;
}
if (Object.keys(updateData).length > 0) {
updates.push({ id: suggestion.questionId, data: updateData });
}
}
});
if (updates.length === 0) {
if (updates.length === 0 && deleteIds.length === 0) {
onOpenChange(false);
return;
}
// Apply updates in parallel with audit logging
await Promise.all(updates.map(update =>
// Apply updates/deletions in parallel with audit logging
const operations: Promise<unknown>[] = updates.map(update =>
auditedSupabase.update('questions', update.id, update.data)
));
);
toast.success(`Updated ${updates.length} questions`);
if (deleteIds.length > 0) {
operations.push(
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
if (result.error) throw result.error;
})
);
}
await Promise.all(operations);
const updateMessage = updates.length > 0 ? `Updated ${updates.length} questions` : null;
const deleteMessage = deleteIds.length > 0 ? `Deleted ${deleteIds.length} questions` : null;
const message = [updateMessage, deleteMessage].filter(Boolean).join('. ');
if (message) {
toast.success(message);
}
onUpdated();
handleClose();
} catch (error) {
@@ -190,6 +289,9 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
setSuggestions([]);
setSelectedTextIds(new Set());
setSelectedTargetIds(new Set());
setSelectedPropsIds(new Set());
setSelectedIntensityIds(new Set());
setSelectedDeleteIds(new Set());
}, 300);
};
@@ -207,6 +309,27 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
setSelectedTargetIds(next);
};
const togglePropsSelection = (id: string) => {
const next = new Set(selectedPropsIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedPropsIds(next);
};
const toggleIntensitySelection = (id: string) => {
const next = new Set(selectedIntensityIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIntensityIds(next);
};
const toggleDeleteSelection = (id: string) => {
const next = new Set(selectedDeleteIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedDeleteIds(next);
};
const formatTargets = (targets: string[] | null) => {
if (!targets || targets.length === 0) return 'All';
return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', ');
@@ -217,35 +340,89 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', ');
};
const formatProps = (props: string[] | null | undefined) => {
if (!props || props.length === 0) return 'None';
return props.join(', ');
};
const formatDeleteCategory = (category?: DeletionAnalysis['category']) => {
switch (category) {
case 'duplicate':
return 'Duplicate';
case 'redundant':
return 'Redundant';
case 'off-tone':
return 'Off tone';
case 'unsafe':
return 'Unsafe';
case 'too-vague':
return 'Too vague';
case 'broken':
return 'Broken';
case 'off-topic':
return 'Off topic';
default:
return 'Delete';
}
};
const getQuestionTextById = (id?: string | null) => {
if (!id) return null;
const match = questions.find(q => q.id === id);
return match ? match.text : null;
};
// Count suggestions by type
const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length;
const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length;
const totalSelectedCount = selectedTextIds.size + selectedTargetIds.size;
const propsSuggestionCount = suggestions.filter(s => s.propsSuggestion).length;
const intensitySuggestionCount = suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).length;
const deleteSuggestionCount = suggestions.filter(s => s.deleteSuggestion).length;
const effectiveTextCount = Array.from(selectedTextIds).filter(id => !selectedDeleteIds.has(id)).length;
const effectiveTargetCount = Array.from(selectedTargetIds).filter(id => !selectedDeleteIds.has(id)).length;
const effectivePropsCount = Array.from(selectedPropsIds).filter(id => !selectedDeleteIds.has(id)).length;
const effectiveIntensityCount = Array.from(selectedIntensityIds).filter(id => !selectedDeleteIds.has(id)).length;
const totalSelectedCount = effectiveTextCount + effectiveTargetCount + effectivePropsCount + effectiveIntensityCount + selectedDeleteIds.size;
const hasSuggestions = textSuggestionCount > 0 || targetSuggestionCount > 0 || propsSuggestionCount > 0 || intensitySuggestionCount > 0 || deleteSuggestionCount > 0;
// Select all/none helpers
const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId)));
const selectNoneText = () => setSelectedTextIds(new Set());
const selectAllTargets = () => setSelectedTargetIds(new Set(suggestions.filter(s => s.targetSuggestion).map(s => s.questionId)));
const selectNoneTargets = () => setSelectedTargetIds(new Set());
const selectAllProps = () => setSelectedPropsIds(new Set(suggestions.filter(s => s.propsSuggestion).map(s => s.questionId)));
const selectNoneProps = () => setSelectedPropsIds(new Set());
const selectAllIntensity = () => setSelectedIntensityIds(new Set(
suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).map(s => s.questionId)
));
const selectNoneIntensity = () => setSelectedIntensityIds(new Set());
const selectAllDeletes = () => setSelectedDeleteIds(new Set(suggestions.filter(s => s.deleteSuggestion).map(s => s.questionId)));
const selectNoneDeletes = () => setSelectedDeleteIds(new Set());
const selectAll = () => {
selectAllText();
selectAllTargets();
selectAllProps();
selectAllIntensity();
selectAllDeletes();
};
const selectNone = () => {
selectNoneText();
selectNoneTargets();
selectNoneProps();
selectNoneIntensity();
selectNoneDeletes();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
Review Questions
</DialogTitle>
<DialogDescription>
AI-powered analysis to improve text phrasing and fix audience targeting.
AI-powered analysis to improve phrasing, identify props, fix targeting, and suggest deletions.
</DialogDescription>
</DialogHeader>
@@ -258,7 +435,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
<p className="text-muted-foreground max-w-lg mx-auto">
The AI will analyze all questions and suggest improvements for:
The AI will analyze all questions and suggest improvements or removals for:
</p>
</div>
<div className="flex gap-4">
@@ -270,6 +447,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<ShieldCheck className="h-5 w-5 text-indigo-400" />
<span className="text-sm font-medium text-indigo-300">Audience Targeting</span>
</div>
<div className="flex items-center gap-2 bg-cyan-500/20 px-4 py-2 rounded-lg">
<Package className="h-5 w-5 text-cyan-400" />
<span className="text-sm font-medium text-cyan-300">Required Props</span>
</div>
<div className="flex items-center gap-2 bg-amber-500/20 px-4 py-2 rounded-lg">
<Gauge className="h-5 w-5 text-amber-400" />
<span className="text-sm font-medium text-amber-300">Intensity Level</span>
</div>
<div className="flex items-center gap-2 bg-rose-500/20 px-4 py-2 rounded-lg">
<Trash2 className="h-5 w-5 text-rose-400" />
<span className="text-sm font-medium text-rose-300">Deletion Suggestions</span>
</div>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
{analyzing ? (
@@ -284,7 +473,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</div>
) : (
<div className="space-y-4">
{suggestions.length === 0 ? (
{!hasSuggestions ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
All questions look great! No improvements needed.
@@ -302,6 +491,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<ShieldCheck className="h-4 w-4 text-indigo-500" />
{targetSuggestionCount} target fixes
</span>
<span className="flex items-center gap-1">
<Package className="h-4 w-4 text-cyan-500" />
{propsSuggestionCount} props fixes
</span>
<span className="flex items-center gap-1">
<Gauge className="h-4 w-4 text-amber-500" />
{intensitySuggestionCount} intensity fixes
</span>
<span className="flex items-center gap-1">
<Trash2 className="h-4 w-4 text-rose-500" />
{deleteSuggestionCount} delete suggestions
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={selectAll}>
@@ -313,12 +514,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</div>
</div>
<div className="border rounded-md">
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Question</TableHead>
<TableHead>
<TableHead className="w-[18%] min-w-[180px]">Question</TableHead>
<TableHead className="w-[26%]">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-purple-500" />
Text Fix
@@ -332,7 +533,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</Button>
</div>
</TableHead>
<TableHead>
<TableHead className="w-[18%]">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-indigo-500" />
Target Fix
@@ -346,32 +547,78 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</Button>
</div>
</TableHead>
<TableHead className="w-[18%]">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-cyan-500" />
Props
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedPropsIds.size === propsSuggestionCount ? selectNoneProps() : selectAllProps()}
>
{selectedPropsIds.size === propsSuggestionCount ? 'None' : 'All'}
</Button>
</div>
</TableHead>
<TableHead className="w-[10%]">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-amber-500" />
Intensity
{intensitySuggestionCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedIntensityIds.size === intensitySuggestionCount ? selectNoneIntensity() : selectAllIntensity()}
>
{selectedIntensityIds.size === intensitySuggestionCount ? 'None' : 'All'}
</Button>
)}
</div>
</TableHead>
<TableHead className="w-[10%]">
<div className="flex items-center gap-2">
<Trash2 className="h-4 w-4 text-rose-500" />
Delete
{deleteSuggestionCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => selectedDeleteIds.size === deleteSuggestionCount ? selectNoneDeletes() : selectAllDeletes()}
>
{selectedDeleteIds.size === deleteSuggestionCount ? 'None' : 'All'}
</Button>
)}
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => (
<TableRow key={suggestion.questionId}>
{/* Question Column */}
<TableCell className="align-top">
<div className="text-sm font-medium line-clamp-2">{suggestion.question.text}</div>
<TableCell className="align-top py-3">
<div className="text-sm font-medium">{suggestion.question.text}</div>
{suggestion.question.partner_text && (
<div className="text-xs text-muted-foreground mt-1 italic line-clamp-1">
<div className="text-xs text-muted-foreground mt-1.5 italic">
Partner: {suggestion.question.partner_text}
</div>
)}
</TableCell>
{/* Text Fix Column */}
<TableCell className="align-top">
<TableCell className="align-top py-3">
{suggestion.textSuggestion ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
? 'bg-purple-500/20 border-purple-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleTextSelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedTextIds.has(suggestion.questionId)}
onCheckedChange={() => toggleTextSelection(suggestion.questionId)}
@@ -382,11 +629,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
{suggestion.textSuggestion.suggested_text}
</div>
{suggestion.textSuggestion.suggested_partner_text && (
<div className="text-xs text-purple-400 mt-1 italic">
<div className="text-xs text-purple-400 mt-1.5 italic">
Partner: {suggestion.textSuggestion.suggested_partner_text}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
<div className="text-xs text-muted-foreground mt-2">
{suggestion.textSuggestion.reason}
</div>
</div>
@@ -401,16 +648,16 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</TableCell>
{/* Target Fix Column */}
<TableCell className="align-top">
<TableCell className="align-top py-3">
{suggestion.targetSuggestion ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
? 'bg-indigo-500/20 border-indigo-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleTargetSelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedTargetIds.has(suggestion.questionId)}
onCheckedChange={() => toggleTargetSelection(suggestion.questionId)}
@@ -418,26 +665,28 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
/>
<div className="flex-1 min-w-0 space-y-2">
{/* Couple targets */}
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Couples:</span>
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatTargets(suggestion.question.allowed_couple_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
<Badge className="text-xs bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
{formatTargets(suggestion.targetSuggestion.suggested_targets)}
</Badge>
</div>
{/* Initiator */}
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-500/50">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Initiator:</span>
<Badge variant="outline" className="text-xs text-muted-foreground border-orange-500/50">
{formatInitiator(suggestion.question.target_user_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
<Badge className="text-xs bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground mt-2">
{suggestion.targetSuggestion.reason}
</div>
</div>
@@ -450,6 +699,130 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</div>
)}
</TableCell>
{/* Props Column */}
<TableCell className="align-top py-3">
{suggestion.propsSuggestion ? (
<div
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedPropsIds.has(suggestion.questionId)
? 'bg-cyan-500/20 border-cyan-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => togglePropsSelection(suggestion.questionId)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedPropsIds.has(suggestion.questionId)}
onCheckedChange={() => togglePropsSelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Current:</span>
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatProps(suggestion.question.required_props)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-xs bg-cyan-500/30 text-cyan-300 hover:bg-cyan-500/30">
{formatProps(suggestion.propsSuggestion.suggested_required_props)}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-2">
{suggestion.propsSuggestion.reason}
</div>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground p-2">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
No changes needed
</div>
)}
</TableCell>
{/* Intensity Column */}
<TableCell className="align-top py-3">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs">
Current: {suggestion.question.intensity}
</Badge>
</div>
{suggestion.textSuggestion?.suggested_intensity != null ? (
<div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedIntensityIds.has(suggestion.questionId)
? 'bg-amber-500/20 border-amber-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleIntensitySelection(suggestion.questionId)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={selectedIntensityIds.has(suggestion.questionId)}
onCheckedChange={() => toggleIntensitySelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-xs bg-amber-500/30 text-amber-300 hover:bg-amber-500/30">
{suggestion.textSuggestion.suggested_intensity}
</Badge>
</div>
{suggestion.textSuggestion.intensity_reason && (
<div className="text-xs text-muted-foreground mt-1">
{suggestion.textSuggestion.intensity_reason}
</div>
)}
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
Correct
</div>
)}
</TableCell>
{/* Delete Column */}
<TableCell className="align-top py-3">
{suggestion.deleteSuggestion ? (
<div
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedDeleteIds.has(suggestion.questionId)
? 'bg-rose-500/20 border-rose-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50'
}`}
onClick={() => toggleDeleteSelection(suggestion.questionId)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedDeleteIds.has(suggestion.questionId)}
onCheckedChange={() => toggleDeleteSelection(suggestion.questionId)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-rose-300 font-medium">
{formatDeleteCategory(suggestion.deleteSuggestion.category)} recommended
</div>
{suggestion.deleteSuggestion.duplicate_of_id && (
<div className="text-xs text-rose-400 mt-1">
Duplicate of: {getQuestionTextById(suggestion.deleteSuggestion.duplicate_of_id) || 'Another question'}
</div>
)}
<div className="text-xs text-muted-foreground mt-2">
{suggestion.deleteSuggestion.reason}
</div>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted-foreground p-2">
<Check className="h-4 w-4 text-green-500 inline mr-1" />
Keep
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -478,7 +851,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
Applying...
</>
) : (
`Apply ${totalSelectedCount} Changes`
`Apply ${totalSelectedCount} Actions`
)}
</Button>
</>
@@ -109,6 +109,8 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
});
} else if (segment === 'questions' && params.packId) {
breadcrumbs.push({ label: 'Questions', href: currentPath });
} else if (segment === 'tags') {
breadcrumbs.push({ label: 'Tags', href: '/tags' });
} else if (segment === 'users') {
breadcrumbs.push({ label: 'Users', href: '/users' });
// If we have a userId, add the user's name as a breadcrumb
@@ -18,6 +18,9 @@ import {
Home,
Activity,
AlertTriangle,
PieChart,
Tags,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -47,6 +50,24 @@ const contentNav: NavItem[] = [
icon: <LayoutGrid className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
},
{
label: 'Dare Packs',
href: '/dare-packs',
icon: <Zap className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
{
label: 'Question Analytics',
href: '/question-analytics',
icon: <PieChart className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS,
},
{
label: 'Tags',
href: '/tags',
icon: <Tags className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
];
const usersNav: NavItem[] = [
+3 -2
View File
@@ -3,15 +3,16 @@ import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked, onCheckedChange, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="checkbox"
aria-checked={checked}
+25 -106
View File
@@ -1,48 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { IconType } from 'react-icons';
import {
IoHeartOutline,
IoHeartHalfOutline,
IoHeartCircleOutline,
IoChatbubblesOutline,
IoChatboxOutline,
IoMailOutline,
IoFlowerOutline,
IoWineOutline,
IoRestaurantOutline,
IoCafeOutline,
IoAirplaneOutline,
IoCarOutline,
IoCompassOutline,
IoMapOutline,
IoHomeOutline,
IoPeopleOutline,
IoPersonOutline,
IoEyeOffOutline,
IoKeyOutline,
IoLockClosedOutline,
IoDiceOutline,
IoGiftOutline,
IoSparklesOutline,
IoStarOutline,
IoFlameOutline,
IoFlashOutline,
IoMoonOutline,
IoSunnyOutline,
IoFlagOutline,
IoCalendarOutline,
IoCheckboxOutline,
IoTrophyOutline,
IoLayersOutline,
IoCubeOutline,
IoFolderOutline,
IoBookmarkOutline,
IoBulbOutline,
IoColorWandOutline,
IoSyncOutline,
IoRefreshOutline,
IoBriefcaseOutline,
} from 'react-icons/io5';
import * as Io5Icons from 'react-icons/io5';
import { Button } from './button';
import { Input } from './input';
@@ -51,68 +9,28 @@ interface IconOption {
Icon: IconType;
}
const ICON_OPTIONS: IconOption[] = [
// Relationships & Love
{ name: 'heart-outline', Icon: IoHeartOutline },
{ name: 'heart-half-outline', Icon: IoHeartHalfOutline },
{ name: 'heart-circle-outline', Icon: IoHeartCircleOutline },
/**
* Convert PascalCase component name to kebab-case Ionicon name.
* e.g., "IoHeartOutline" -> "heart-outline"
*/
function toKebabCase(componentName: string): string {
// Remove "Io" prefix
const withoutPrefix = componentName.slice(2);
// Convert PascalCase to kebab-case
return withoutPrefix
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}
// Communication
{ name: 'chatbubbles-outline', Icon: IoChatbubblesOutline },
{ name: 'chatbox-outline', Icon: IoChatboxOutline },
{ name: 'mail-outline', Icon: IoMailOutline },
// Romance & Dates
{ name: 'flower-outline', Icon: IoFlowerOutline },
{ name: 'wine-outline', Icon: IoWineOutline },
{ name: 'restaurant-outline', Icon: IoRestaurantOutline },
{ name: 'cafe-outline', Icon: IoCafeOutline },
// Adventure & Travel
{ name: 'airplane-outline', Icon: IoAirplaneOutline },
{ name: 'car-outline', Icon: IoCarOutline },
{ name: 'compass-outline', Icon: IoCompassOutline },
{ name: 'map-outline', Icon: IoMapOutline },
// Home & Family
{ name: 'home-outline', Icon: IoHomeOutline },
{ name: 'people-outline', Icon: IoPeopleOutline },
{ name: 'person-outline', Icon: IoPersonOutline },
// Mystery & Secrets
{ name: 'eye-off-outline', Icon: IoEyeOffOutline },
{ name: 'key-outline', Icon: IoKeyOutline },
{ name: 'lock-closed-outline', Icon: IoLockClosedOutline },
// Fun & Games
{ name: 'dice-outline', Icon: IoDiceOutline },
{ name: 'gift-outline', Icon: IoGiftOutline },
{ name: 'sparkles-outline', Icon: IoSparklesOutline },
{ name: 'star-outline', Icon: IoStarOutline },
// Intimacy
{ name: 'flame-outline', Icon: IoFlameOutline },
{ name: 'flash-outline', Icon: IoFlashOutline },
{ name: 'moon-outline', Icon: IoMoonOutline },
{ name: 'sunny-outline', Icon: IoSunnyOutline },
// Goals & Planning
{ name: 'flag-outline', Icon: IoFlagOutline },
{ name: 'calendar-outline', Icon: IoCalendarOutline },
{ name: 'checkbox-outline', Icon: IoCheckboxOutline },
{ name: 'trophy-outline', Icon: IoTrophyOutline },
// General
{ name: 'layers-outline', Icon: IoLayersOutline },
{ name: 'cube-outline', Icon: IoCubeOutline },
{ name: 'folder-outline', Icon: IoFolderOutline },
{ name: 'bookmark-outline', Icon: IoBookmarkOutline },
{ name: 'bulb-outline', Icon: IoBulbOutline },
{ name: 'color-wand-outline', Icon: IoColorWandOutline },
{ name: 'sync-outline', Icon: IoSyncOutline },
{ name: 'refresh-outline', Icon: IoRefreshOutline },
{ name: 'briefcase-outline', Icon: IoBriefcaseOutline },
];
// Build icon options from all Io5 icons dynamically
const ICON_OPTIONS: IconOption[] = Object.entries(Io5Icons)
.filter(([name]) => name.startsWith('Io'))
.map(([componentName, Icon]) => ({
name: toKebabCase(componentName),
Icon: Icon as IconType,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
acc[option.name] = option;
@@ -168,8 +86,9 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
}, [query]);
const SelectedIcon = ICONS_BY_NAME[value]?.Icon;
const buttonLabel = SelectedIcon ? value : value ? 'Legacy icon' : 'Select an icon';
const selectedOption = ICONS_BY_NAME[value];
const SelectedIcon = selectedOption?.Icon;
const buttonLabel = selectedOption ? value : value ? 'Legacy icon' : 'Select an icon';
return (
<div className="relative" ref={containerRef}>
@@ -0,0 +1,97 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
type PaginationControlsProps = {
page: number;
pageSize: number;
totalCount: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSizeOptions?: number[];
showPageSize?: boolean;
className?: string;
};
export function PaginationControls({
page,
pageSize,
totalCount,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 25, 50],
showPageSize = true,
className,
}: PaginationControlsProps) {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages);
const start = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
const end = totalCount === 0 ? 0 : Math.min(safePage * pageSize, totalCount);
return (
<div
className={cn(
'flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between',
className
)}
>
<div>
{totalCount === 0
? 'No results'
: `Showing ${start} to ${end} of ${totalCount}`}
</div>
<div className="flex flex-wrap items-center gap-2">
{showPageSize && onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">Rows</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="h-8 w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, safePage - 1))}
disabled={safePage <= 1}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Prev
</Button>
<span className="text-xs text-muted-foreground">
Page {safePage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
disabled={safePage >= totalPages}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+17
View File
@@ -3,6 +3,7 @@ import { supabase } from '@/config';
export interface CouncilGenerator {
model: string;
temperature?: number;
}
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
@@ -11,18 +12,34 @@ export interface AiConfig {
id: string;
openrouter_api_key: string | null;
default_model: string | null;
default_temperature: number | null;
model_generate: string | null;
temperature_generate: number | null;
model_fix: string | null;
temperature_fix: number | null;
model_polish: string | null;
temperature_polish: number | null;
council_enabled: boolean;
council_generator_model: string | null; // Legacy - kept for backwards compatibility
council_generators: CouncilGenerator[] | null; // New array of generators
council_reviewer_model: string | null;
council_reviewer_temperature: number | null;
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
classifier_enabled: boolean | null;
classifier_model: string | null;
classifier_temperature: number | null;
classifier_prompt: string | null;
heuristics_enabled: boolean | null;
heuristic_min_text_length: number | null;
heuristic_whitelist_max_length: number | null;
heuristic_skip_if_no_alnum: boolean | null;
heuristic_skip_media_without_text: boolean | null;
heuristic_record_reason: boolean | null;
heuristic_use_default_whitelist: boolean | null;
heuristic_use_default_keywords: boolean | null;
heuristic_whitelist: string | null;
heuristic_keyword_triggers: string | null;
updated_at: string | null;
updated_by: string | null;
}
+1
View File
@@ -4,6 +4,7 @@ import { supabase } from '@/config';
export interface AppConfig {
id: string;
answer_gap_threshold: number;
daily_response_limit: number;
updated_at: string | null;
updated_by: string | null;
}
+2 -2
View File
@@ -86,10 +86,10 @@ export const auditedSupabase = {
/**
* Update a single record with audit logging
*/
async update<T extends { id: string }>(
async update<T extends object = Record<string, unknown>>(
table: string,
id: string,
updates: Partial<T>
updates: Partial<T> | Record<string, unknown>
): Promise<SingleResult<T>> {
// Fetch old values before update
let oldValues: Record<string, unknown> | null = null;
+10 -6
View File
@@ -3,7 +3,8 @@ import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner';
type SupabaseFilterFn<T> = (query: ReturnType<typeof supabase.from>) => ReturnType<typeof supabase.from>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SupabaseFilterFn = (query: any) => any;
interface UseCrudOperationsOptions<T> {
/**
@@ -89,15 +90,16 @@ export function useCrudOperations<T extends { id: string }>({
* @param filter Optional filter function to modify the query
*/
const fetchAll = useCallback(async (
filter?: SupabaseFilterFn<T>
filter?: SupabaseFilterFn
): Promise<T[]> => {
setLoading(true);
setError(null);
try {
let query = supabase.from(table).select(select);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = supabase.from(table).select(select);
if (filter) {
query = filter(query) as typeof query;
query = filter(query);
}
if (orderBy) {
@@ -155,7 +157,8 @@ export function useCrudOperations<T extends { id: string }>({
): Promise<T | null> => {
const { refetch = true, silent = false } = options;
try {
const { data: created, error: createError } = await auditedSupabase.insert(table, data);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
if (createError) throw createError;
@@ -167,7 +170,8 @@ export function useCrudOperations<T extends { id: string }>({
await fetchAll();
}
return created as T;
// auditedSupabase.insert returns T[] | null, get first element
return created && created.length > 0 ? (created[0] as T) : null;
} catch (err) {
if (!silent) {
toast.error(defaultMessages.createError);
+2 -2
View File
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
* ```
*/
export function useEntityForm<
TFormData extends Record<string, unknown>,
TEntity extends Record<string, unknown> = TFormData
TFormData extends object,
TEntity extends object = TFormData
>(
initialFormData: TFormData,
/**
@@ -0,0 +1,102 @@
// =============================================================================
// Deletion Analysis Functions
// Suggest removals for low-quality or duplicate questions
// =============================================================================
import { getOpenAI, getModel, getTemperature } from '../client';
import type { DeletionAnalysis } from '../types';
/**
* Analyze questions and suggest deletions (duplicates, unsafe, etc.)
*/
export async function analyzeQuestionDeletions(
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
isExplicit: boolean = false
): Promise<DeletionAnalysis[]> {
const openai = getOpenAI();
const simplifiedQuestions = questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
intensity: q.intensity,
}));
const toneInstruction = isExplicit
? 'CONTENT: EXPLICIT pack. Adult content is allowed but must be consensual, safe, and clear. Avoid coercion, minors, illegal acts, or unsafe suggestions.'
: 'CONTENT: NON-EXPLICIT pack. Do NOT include explicit sexual acts, crude language, or NSFW content. If the core action is sexual, it should be removed, not rewritten.';
const prompt = `<task>
Identify questions that should be DELETED instead of edited.
Only include questions that are better removed than fixed.
</task>
<content_type>
${toneInstruction}
</content_type>
<deletion_categories>
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
- redundant: minor variation with no added value
- off-tone: violates explicit vs non-explicit rules
- unsafe: coercion, minors, illegal acts, or consent violations
- too-vague: not actionable or not a clear proposal
- broken: grammar/structure too broken to repair without inventing content
- off-topic: not relevant to a couples intimacy app
</deletion_categories>
<rules>
1. Prefer edits over deletions when possible.
2. Duplicates: delete the weaker/less specific version and keep the clearer one.
3. If duplicate, set duplicate_of_id to the kept question's id.
4. Do NOT delete for minor wording issues or intensity mismatches.
5. IMPORTANT: Asymmetric versions of the same activity are NOT duplicates. If one question has text="Do X to your partner" and another has text="Let your partner do X to you", these are DIFFERENT questions because they have different initiators. Only flag as duplicate if both questions have the SAME initiator doing the SAME action.
</rules>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"deletions": [
{
"id": string,
"category": "duplicate"|"redundant"|"off-tone"|"unsafe"|"too-vague"|"broken"|"off-topic",
"reason": string,
"duplicate_of_id": string|null
}
]
}
Only return questions that should be deleted.
</output_format>`;
const systemMessage = isExplicit
? 'You are a strict content quality reviewer for an adult couples app. Remove only what should be deleted. Always respond with valid JSON only.'
: 'You are a strict content quality reviewer for a couples app. Remove only what should be deleted. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({
model: getModel('fix'),
messages: [
{
role: 'system',
content: systemMessage,
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: getTemperature('fix', 0.2),
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
let jsonContent = content.trim();
if (jsonContent.startsWith('```')) {
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
const parsed = JSON.parse(jsonContent);
return parsed.deletions || [];
}
+73 -2
View File
@@ -1,8 +1,53 @@
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
export interface ImageSafetyAnalysis {
category: 'SAFE' | 'ADULT_CONTENT' | 'VIOLENCE' | 'MINORS' | 'ILLEGAL' | 'UNCERTAIN';
severity: 'none' | 'low' | 'medium' | 'high' | 'critical';
description: string;
concerns: string[];
isAppropriate: boolean;
}
const SAFETY_ANALYSIS_PROMPT = `<task>
Analyze this image for content safety in the context of an adult couples intimacy app.
</task>
<context>
This app is for ADULT couples (25-45) to explore intimacy. Adult content between consenting adults is expected and acceptable. The concern is inappropriate content like violence, minors, or illegal activity.
</context>
<categories>
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults)
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
- ILLEGAL: Drug use, criminal activity, non-consensual content
- UNCERTAIN: Cannot determine, needs human review
</categories>
<severity_levels>
- none: No safety concerns at all
- low: Minor flag, likely fine but worth noting
- medium: Should be reviewed by admin
- high: Likely inappropriate, requires immediate review
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
</severity_levels>
<output_format>
Return a JSON object:
{
"category": "SAFE"|"ADULT_CONTENT"|"VIOLENCE"|"MINORS"|"ILLEGAL"|"UNCERTAIN",
"severity": "none"|"low"|"medium"|"high"|"critical",
"description": "Brief factual description of what's shown (max 50 words)",
"concerns": ["List of specific concerns if any, empty array if none"],
"isAppropriate": true|false // false for VIOLENCE, MINORS, ILLEGAL or high/critical severity
}
</output_format>`;
export async function describeImage(imageBase64OrUrl: string): Promise<string> {
const openai = getOpenAI();
const model = getModel('describe_image');
const temperature = getTemperature('describe_image', 0.0); // Deterministic for safety
// If it's a data URL or http URL, use it as is.
// If it's raw base64, prepend the data URI scheme.
@@ -18,7 +63,7 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
{
role: 'user',
content: [
{ type: 'text', text: 'Describe this image in detail. Focus on any potentially inappropriate content or safety concerns.' },
{ type: 'text', text: SAFETY_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: {
@@ -29,6 +74,7 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
},
],
max_tokens: 300,
temperature: temperature,
});
return response.choices[0].message.content || 'No description available.';
@@ -37,3 +83,28 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
throw error;
}
}
/**
* Analyze image safety and return structured result
*/
export async function analyzeImageSafety(imageBase64OrUrl: string): Promise<ImageSafetyAnalysis> {
const rawResult = await describeImage(imageBase64OrUrl);
try {
// Try to parse as JSON
let jsonContent = rawResult.trim();
if (jsonContent.startsWith('```')) {
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
return JSON.parse(jsonContent) as ImageSafetyAnalysis;
} catch {
// If parsing fails, return a default "uncertain" result
return {
category: 'UNCERTAIN',
severity: 'medium',
description: rawResult.slice(0, 200),
concerns: ['Could not parse safety analysis result'],
isAppropriate: false,
};
}
}
+89
View File
@@ -0,0 +1,89 @@
// =============================================================================
// Props Analysis Functions
// Identify required props/accessories for questions
// =============================================================================
import { getOpenAI, getModel, getTemperature } from '../client';
import type { PropsAnalysis } from '../types';
/**
* Analyze questions for required props/accessories
*/
export async function analyzeQuestionProps(
questions: {
id: string;
text: string;
partner_text?: string | null;
required_props?: string[] | null;
}[],
existingProps: string[] = []
): Promise<PropsAnalysis[]> {
const openai = getOpenAI();
const normalizedExistingProps = existingProps
.map(prop => prop.trim())
.filter(Boolean);
const simplifiedQuestions = questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
current_required_props: q.required_props ?? null,
}));
const prompt = `<task>
Identify any props/accessories required to perform each question.
Only include questions where required props should change (missing, incorrect, or overly generic).
</task>
<existing_props>
Prefer these exact prop names when possible (reuse existing names instead of inventing new ones):
${JSON.stringify(normalizedExistingProps)}
</existing_props>
<rules>
1. Props are physical items/accessories, not body parts or acts.
2. Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator").
3. If a prop is essentially the same as an existing one, reuse the existing name.
4. Avoid making up new variants when a close existing name is available.
5. Example: if "remote vibrator" exists, map "wifi" or "app-controlled" variants to "remote vibrator".
6. Use specific items when clearly stated; otherwise allow "toy" or "props".
7. If no props are required, set suggested_required_props to null.
8. Only return entries where the suggested props differ from current_required_props.
</rules>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"analysis": [
{
"id": string,
"suggested_required_props": string[]|null,
"reason": string
}
]
}
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
messages: [
{
role: 'system',
content: 'You are a content analyst for a couples app. Identify required props/accessories and return valid JSON.'
},
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: getTemperature('fix', 0.3),
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.analysis || [];
}
+50 -42
View File
@@ -3,7 +3,7 @@
// Analyze questions for gender targeting and couple compatibility
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { TargetAnalysis } from '../types';
/**
@@ -28,59 +28,67 @@ export async function analyzeQuestionTargets(
current_initiator: q.target_user_genders
}));
const prompt = `Analyze the following questions for a couples app and suggest:
1. "allowed_couple_genders" (which couple types can see this question)
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
const prompt = `<task>
Analyze questions for couple targeting and initiator targeting.
Only include questions where you have a recommendation to change current settings.
</task>
COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
<targeting_options>
Couple targets (allowed_couple_genders): 'male+male', 'female+male', 'female+female'
Initiator targets (target_user_genders): 'male', 'female', 'non-binary'
</targeting_options>
RULES FOR COUPLE TARGETS (allowed_couple_genders):
- Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
- Limit ONLY if there is a clear, explicit anatomical requirement (e.g. genitalia-specific terms).
- SEX TOYS ARE GENDER-NEUTRAL: Vibrators, dildos, plugs, etc. can be used by ANYONE. Do NOT restrict based on toys.
- "Control your partner's vibrator" -> ALL couples, ANY initiator (vibrators work on any body)
- "Use a butt plug" -> ALL couples (everyone has a butt)
<couple_targeting_rules>
DEFAULT: null (ALL couples) unless explicit anatomical requirement
Examples of ACTUAL restrictions:
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
- "Vibrator", "dildo", "toy" -> NO restriction, works for everyone
Restrict ONLY when:
- Activity requires penis -> ['male+male', 'female+male'] (exclude F+F)
- Activity requires vagina -> ['female+male', 'female+female'] (exclude M+M)
RULES FOR INITIATOR (target_user_genders):
- ONLY relevant for TWO-PART questions (those with partner_text set).
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
- ONLY set initiator if there is an EXPLICIT anatomical requirement in the text.
- SEX TOYS DO NOT IMPLY GENDER: "vibrator", "dildo", "toy", "plug" -> initiator: null (anyone can use/control toys)
- If text is gender-neutral, leave as null. When in doubt, use null.
Key considerations for initiator:
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
4. Is the action truly symmetric with no gendered context? -> null
SEX TOYS ARE GENDER-NEUTRAL:
- Vibrators, dildos, plugs work on ANYONE -> null (no restriction)
- "Control your partner's vibrator" -> null
- "Use a butt plug" -> null (everyone has a butt)
Examples:
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
- "Blowjob" -> needs penis -> ['male+male', 'female+male']
- "Lick pussy" -> needs vagina -> ['female+male', 'female+female']
- "Use a toy" -> gender-neutral -> null
</couple_targeting_rules>
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
<initiator_targeting_rules>
ONLY for asymmetric questions (those with partner_text).
DEFAULT: null (anyone can initiate)
Structure:
Set initiator ONLY when "text" field has explicit anatomical requirement:
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
- "Lick my cum off your body" -> "my cum" = initiator has penis -> ['male']
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
- "Give your partner a massage" -> gender-neutral -> null
- "Let her ride you" -> "her" implies female partner -> ['male']
- Sex toys -> null (anyone can use/control toys)
When in doubt, use null.
</initiator_targeting_rules>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"analysis": [
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
{
"id": string,
"suggested_targets": string[]|null, // e.g., ["male+male", "female+male"] or null
"suggested_initiator": string[]|null, // e.g., ["male"] or null
"reason": string // Explanation of recommendation
}
]
}
Questions:
${JSON.stringify(simplifiedQuestions)}
`;
Only include questions where targeting should change.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
@@ -92,7 +100,7 @@ ${JSON.stringify(simplifiedQuestions)}
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: 0.5,
temperature: getTemperature('fix', 0.5),
});
const content = response.choices[0].message.content;
+68 -42
View File
@@ -3,14 +3,14 @@
// Analyze and suggest improvements for question text
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { TextAnalysis } from '../types';
/**
* Analyze question text and suggest improvements
*/
export async function analyzeQuestionText(
questions: { id: string; text: string; partner_text?: string | null }[],
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
isExplicit: boolean = false
): Promise<TextAnalysis[]> {
const openai = getOpenAI();
@@ -19,6 +19,7 @@ export async function analyzeQuestionText(
id: q.id,
text: q.text,
partner_text: q.partner_text,
intensity: q.intensity,
}));
const toneInstruction = isExplicit
@@ -38,56 +39,81 @@ export async function analyzeQuestionText(
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
const prompt = `<task>
Analyze the following questions and suggest improved text that follows our style guidelines.
Only include questions that NEED improvement - skip well-phrased ones.
</task>
<content_type>
${toneInstruction}
</content_type>
<app_context>
The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" relative to a specific action, NOT interview questions.
Cards should be "Proposals" for specific actions, NOT interview questions.
</app_context>
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
1. SYMMETRIC Activities (Single Card - partner_text should be null):
- Use for shared experiences where both partners do the same thing together.
- Phrasing: Direct action proposal.
<question_types>
1. SYMMETRIC Activities (partner_text = null):
- Both partners do the same thing together
- ${symmetricExamples}
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
- BAD: "Have you ever thought about...", "Do you think we should..."
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
- Use for actions where one partner does something TO/FOR the other, or where roles differ.
- text = what the INITIATOR does (their action/perspective)
- partner_text = what the RECEIVER does/experiences (their action/perspective)
2. ASYMMETRIC Actions (needs both text AND partner_text):
- One partner does something TO/FOR the other
- text = what the INITIATOR does
- partner_text = what the RECEIVER experiences
- ${asymmetricExamples}
- IMPORTANT: partner_text should describe the RECEIVER's experience clearly:
- text: "Send your partner to the bathroom and follow a minute later"
- partner_text: "Go to the bathroom and wait for your partner to join you" (NOT "Wait for your partner to follow you")
- When initiator CAUSES a response (moan, cum, beg, etc.), partner_text should frame as ALLOWING/RECEIVING:
- text: "Make your partner moan in public" -> partner_text: "Let your partner make you moan in public"
- NOT: "Moan for your partner in public" (sounds like deliberate performance, not natural response)
- BAD: "Would you like to...?" (Ambiguous who "me" is)
CRITICAL RULES:
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
2. Keep text SHORT and DIRECT - no explanatory parentheticals. Aim for 5-12 words.
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
4. Make it direct and actionable.
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
7. If a question is ALREADY well-phrased, do NOT include it in the results.
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
9. NEVER MIX ANATOMICALLY INCOMPATIBLE ACTIVITIES - Do NOT combine male-specific and female-specific acts as alternatives in the same question.
10. MAKE PARTNER_TEXT APPEALING - Don't just grammatically flip the text. Make the receiver feel excited.
11. FLAG CLICHES for improvement - "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill" are overused
Partner text rules:
- Describe RECEIVER's experience clearly
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING:
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
* NOT: "Moan for your partner" (sounds forced, not natural)
</question_types>
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
Each object should have:
- id: The question ID
- suggested_text: The improved text (short, direct, no parentheticals)
- suggested_partner_text: The improved partner_text (or null if symmetric)
- reason: Brief explanation of what was improved
<rules>
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
4. Use "your partner" instead of "me", "you", "him", "her"
5. NEVER mix anatomically incompatible activities in same question
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
8. Skip questions that are ALREADY well-phrased
</rules>
Questions:
${JSON.stringify(simplifiedQuestions)}`;
<intensity_guide>
Check if current intensity matches the activity:
1 (Gentle): Emotional bonding, non-sexual (cooking, cuddling, foot massage)
2 (Warm): Romantic, affectionate touch (slow dance, sensual massage, kissing)
3 (Playful): Light sexual exploration (oral, mutual masturbation, light roleplay)
4 (Steamy): Explicit sex, moderate adventure (intercourse, light bondage, anal play)
5 (Intense): Advanced/BDSM/extreme (impact play, power dynamics, taboo kinks)
If intensity seems wrong, include suggested_intensity and intensity_reason.
</intensity_guide>
<questions_to_analyze>
${JSON.stringify(simplifiedQuestions)}
</questions_to_analyze>
<output_format>
{
"suggestions": [
{
"id": string, // Question ID
"suggested_text": string, // Improved text (short, direct)
"suggested_partner_text": string|null, // Improved partner_text or null if symmetric
"reason": string, // Brief explanation of text improvement
"suggested_intensity": number|null, // 1-5 if intensity seems wrong, null if correct
"intensity_reason": string|null // Why intensity should change (if applicable)
}
]
}
Only include questions that need text improvement OR intensity adjustment.
</output_format>`;
const systemMessage = isExplicit
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
@@ -103,7 +129,7 @@ ${JSON.stringify(simplifiedQuestions)}`;
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: 0.5,
temperature: getTemperature('fix', 0.5),
});
const content = response.choices[0].message.content;
+25
View File
@@ -78,6 +78,31 @@ export function getModel(purpose?: ModelPurpose): string {
}
}
/**
* Get the appropriate temperature for a given purpose
* Uses remote config > default temperature > fallback
*/
export function getTemperature(purpose?: ModelPurpose, fallback?: number): number {
const remoteConfig = getCachedAiConfig();
const defaultTemperature = remoteConfig?.default_temperature;
const baseFallback = defaultTemperature ?? fallback ?? 0.7;
if (!purpose) return baseFallback;
switch (purpose) {
case 'generate':
return remoteConfig?.temperature_generate ?? defaultTemperature ?? fallback ?? 0.9;
case 'fix':
return remoteConfig?.temperature_fix ?? defaultTemperature ?? fallback ?? 0.5;
case 'polish':
return remoteConfig?.temperature_polish ?? defaultTemperature ?? fallback ?? 0.7;
case 'describe_image':
return remoteConfig?.classifier_temperature ?? defaultTemperature ?? fallback ?? 1.0;
default:
return baseFallback;
}
}
/**
* Extract a short display name from a model identifier
* e.g., "anthropic/claude-3.5-sonnet" -> "Claude 3.5 Sonnet"
+116 -66
View File
@@ -31,6 +31,7 @@ export function getCouncilConfig(): CouncilConfig {
enabled: remoteConfig.council_enabled || false,
generators,
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
reviewerTemperature: remoteConfig.council_reviewer_temperature ?? 0.3,
selectionMode: remoteConfig.council_selection_mode || 'whole_set',
cherryPickEnsureIntensityDistribution: remoteConfig.cherry_pick_ensure_intensity_distribution ?? true,
};
@@ -41,6 +42,7 @@ export function getCouncilConfig(): CouncilConfig {
enabled: import.meta.env.VITE_COUNCIL_ENABLED === 'true',
generators: [{ model: import.meta.env.VITE_COUNCIL_GENERATOR_MODEL || 'anthropic/claude-3.5-sonnet' }],
reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5',
reviewerTemperature: 0.3,
selectionMode: 'whole_set',
cherryPickEnsureIntensityDistribution: true,
};
@@ -60,12 +62,11 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
// =============================================================================
export const TONE_LEVELS = [
{ level: 0, label: 'Everyday', description: 'Non-romantic activities - games, cooking, conversations' },
{ level: 1, label: 'Sweet', description: 'Romantic but innocent - compliments, date nights, cuddles' },
{ level: 2, label: 'Flirty', description: 'Playful teasing - flirty texts, light touching, innuendo' },
{ level: 3, label: 'Intimate', description: 'Sensual but soft - massage, making out, showering together' },
{ level: 4, label: 'Sexual', description: 'Standard sexual activities - oral, sex, toys' },
{ level: 5, label: 'Wild', description: 'Extreme/kinky - public sex, creampies, BDSM, exhibitionism' },
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' },
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' },
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
] as const;
// =============================================================================
@@ -74,11 +75,11 @@ export const TONE_LEVELS = [
// =============================================================================
export const INTENSITY_LEVELS = [
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-sexual', color: 'bg-green-500' },
{ level: 2, label: 'Mild', description: 'Hugging, cuddling, gentle kisses, flirty texts', color: 'bg-lime-500' },
{ level: 3, label: 'Moderate', description: 'Making out, massage, sexy/suggestive photos', color: 'bg-yellow-500' },
{ level: 4, label: 'Spicy', description: 'Oral, foreplay, nudes, explicit sexting', color: 'bg-orange-500' },
{ level: 5, label: 'Intense', description: 'Sex, kinks, BDSM, explicit videos', color: 'bg-red-500' },
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding', color: 'bg-green-500' },
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch', color: 'bg-lime-500' },
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery', color: 'bg-yellow-500' },
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure', color: 'bg-orange-500' },
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration', color: 'bg-red-500' },
] as const;
// =============================================================================
@@ -89,41 +90,30 @@ export const INTENSITY_LEVELS = [
export const INTENSITY_GUIDE = `
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
Level 1 - LIGHT (Emotional/Non-Sexual):
- Talking, sharing feelings, compliments, emotional vulnerability
- Non-physical activities: writing letters, planning dates, sharing dreams
- Clean activities with no sexual undertones
- Examples: "Share your biggest dream", "Write a love note", "Plan a surprise date"
Level 1 - GENTLE (Pure emotional connection & non-sexual bonding):
- Focus: Emotional safety, friendship-based activities, quality time
- Examples: Cook together, take a walk holding hands, give non-sexual foot massages, watch a movie cuddling
- Emotional bonding without sexual undertones
Level 2 - MILD (Affectionate/Flirty):
- Light physical affection: holding hands, hugging, cuddling
- Gentle kisses, playing with hair, light touches
- Flirty but non-sexual: dancing together, sleeping in each other's arms
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Send a flirty text"
Level 2 - WARM (Romantic atmosphere & affectionate touch):
- Focus: Creating romantic moments, sensual but non-sexual touch, building anticipation
- Examples: Slow dance in the living room, candlelit bath together (non-sexual), extended kissing sessions, sensual massage with oils
- Romantic intimacy building up to arousal
Level 3 - MODERATE (Sensual/Suggestive):
- Making out, passionate kissing, intimate massage
- Sensual touch over clothes, caressing, teasing
- Suggestive photos (lingerie, shirtless), sexy selfies, teasing texts
- Showering together, skinny dipping
- Examples: "Give a sensual massage", "Send a sexy photo", "Make out somewhere risky"
Level 3 - PLAYFUL (Light sexual exploration & sensual discovery):
- Focus: Sexual touch without penetration, playful experimentation, building arousal
- Examples: Mutual masturbation, oral sex, light roleplay (doctor/patient), using basic toys together
- Fun and exploratory sexual contact
Level 4 - SPICY (Sexual/Explicit Content):
- Oral sex, fingering, handjobs, mutual masturbation
- Foreplay activities, using hands/mouth on private parts
- Nudes, explicit photos, dirty pics, sexting explicit content
- Using toys externally, edging, making your partner cum
- Examples: "Go down on your partner", "Send a nude", "Sext each other something filthy"
Level 4 - STEAMY (Explicit sexual activities & moderate adventure):
- Focus: Full sexual intercourse with adventurous elements, moderate BDSM/kink
- Examples: Sex in different locations/positions, light bondage, anal play with toys, recording consensual intimate moments
- Passionate and adventurous sexual connection
Level 5 - INTENSE (Penetration/Advanced Kinks):
- Sex (vaginal or anal), toys inside, fingers inside
- Kinks: bondage, role-play with power dynamics, BDSM
- Threesomes, exhibitionism, voyeurism
- Making/sharing explicit videos, masturbating on video call
- Examples: "Fuck in a new location", "Try anal", "Record yourselves having sex"
IMPORTANT: Intensity is about SEXUAL CONTENT level, not just physical touch.
Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos = Level 4.
Level 5 - INTENSE (Advanced/BDSM/Extreme exploration):
- Focus: Edge play, power dynamics, intense physical experiences
- Examples: Heavy impact play, advanced bondage, consensual non-consent scenes, multi-partner activities
- Very intense, taboo kinks, and extreme exploration
`;
// =============================================================================
@@ -133,13 +123,11 @@ Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos
export const INTENSITY_GUIDE_SHORT = `
INTENSITY LEVELS:
1 (Light): Talking, emotional sharing, compliments, non-sexual activities
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting, foreplay
5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
IMPORTANT: "Filthy photo", "nude", "dirty pic" = Level 4. "Sexy photo", "lingerie pic" = Level 3.
1 (Gentle): Pure emotional connection, non-sexual bonding (cooking, cuddling, foot massages)
2 (Warm): Romantic atmosphere, affectionate touch (slow dance, sensual massage, kissing)
3 (Playful): Light sexual exploration, sensual discovery (oral, mutual masturbation, light roleplay)
4 (Steamy): Explicit sexual activities, moderate adventure (sex positions, light bondage, anal play)
5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics, taboo kinks)
`;
// =============================================================================
@@ -168,11 +156,11 @@ REVIEW CRITERIA - Score each 1-10:
- No confusing or ambiguous phrasing
4. INTENSITY ACCURACY:
Level 1 (Light): Talking, emotional sharing, compliments, non-sexual
Level 2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
Level 3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
Level 4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting
Level 5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
Level 1 (Gentle): Pure emotional connection, non-sexual bonding
Level 2 (Warm): Romantic atmosphere, affectionate touch
Level 3 (Playful): Light sexual exploration, sensual discovery (oral, toys)
Level 4 (Steamy): Explicit sexual activities, moderate adventure (intercourse, light bondage)
Level 5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics)
5. ANATOMICAL CONSISTENCY:
- No mixed male/female anatomy in alternatives
@@ -184,6 +172,20 @@ REVIEW CRITERIA - Score each 1-10:
- BAD: "Receive oral from your partner"
- GOOD: "Let your partner pleasure you with their mouth"
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
7. COUPLE TARGETING (flag if incorrect):
- Default: ALL couples (null) unless explicit anatomical requirement
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
- Flag if targeting seems too restrictive or missing needed restrictions
8. INITIATOR TARGETING (for asymmetric questions only):
- Default: null (anyone can initiate) unless explicit anatomical requirement
- Set initiator based on WHO DOES the action in "text" field
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
- "Give your partner a massage" -> initiator: null (anyone can give)
- Flag if initiator seems incorrectly assigned
`;
// =============================================================================
@@ -192,24 +194,72 @@ REVIEW CRITERIA - Score each 1-10:
// =============================================================================
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
0: 'WILDNESS LEVEL: EVERYDAY. Generate non-romantic activities only. Focus on communication, teamwork, fun, personal growth, and bonding WITHOUT any romantic or sexual undertones. Examples: "Cook a new recipe together", "Play a board game", "Send your partner a compliment while they\'re at work", "Plan a weekend adventure". NO flirting, NO romance, NO intimacy.',
1: 'WILDNESS LEVEL: SWEET. Generate romantic but innocent activities. Focus on emotional connection, love, and tenderness. Examples: "Write your partner a love letter", "Plan a surprise date night", "Cuddle and watch the sunset", "Give your partner a back massage". NO sexual content.',
2: 'WILDNESS LEVEL: FLIRTY. Generate playful, teasing activities with light innuendo. Examples: "Send a flirty text during the day", "Whisper something suggestive in your partner\'s ear", "Give your partner a lingering kiss goodbye". Suggestive but NOT explicitly sexual.',
3: 'WILDNESS LEVEL: INTIMATE. Generate sensual activities that imply intimacy without being graphic. Examples: "Give your partner a full-body massage", "Shower together", "Make out somewhere unexpected", "Sleep naked together". Passionate but soft - no explicit sex acts.',
4: 'WILDNESS LEVEL: SEXUAL. Generate standard sexual activities. Examples: "Go down on your partner", "Have sex in a new position", "Use a vibrator together", "Wake your partner up with oral". Direct language, avoid clinical terms. This is vanilla sex - adventurous but not extreme.',
5: 'WILDNESS LEVEL: WILD. Generate extreme, kinky, or taboo activities. Think: public sex, exhibitionism, BDSM, risky situations, intense kinks. Examples: "Get creampied by your partner and go to dinner", "Have sex somewhere you might get caught", "Let your partner tie you up and use you", "Edge each other for an hour before allowing release". Push boundaries. Use crude terms when they ARE the activity (cum, cock ring) but tasteful phrasing for general acts (have sex, not fuck). NEVER sanitize "cum" to "come".',
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
2: 'INTIMACY LEVEL: WARM. Focus on creating romantic moments, sensual but non-sexual touch, and building anticipation. Examples: "Slow dance in the living room", "Candlelit bath together (non-sexual)", "Extended kissing sessions", "Sensual massage with oils". Romantic atmosphere & affectionate touch.',
3: 'INTIMACY LEVEL: PLAYFUL. Focus on sexual touch without penetration, playful experimentation, and building arousal. Examples: "Mutual masturbation", "Oral sex", "Light roleplay (doctor/patient)", "Using basic toys together". Light sexual exploration & sensual discovery.',
4: 'INTIMACY LEVEL: STEAMY. Focus on full sexual intercourse with adventurous elements and moderate BDSM/kink. Examples: "Sex in different locations/positions", "Light bondage", "Anal play with toys", "Recording consensual intimate moments". Explicit sexual activities & moderate adventure.',
5: 'INTIMACY LEVEL: INTENSE. Focus on edge play, power dynamics, and intense physical experiences. Examples: "Heavy impact play", "Advanced bondage", "Consensual non-consent scenes", "Multi-partner activities". Advanced/BDSM/Extreme exploration.',
};
// =============================================================================
// CORE LANGUAGE RULES
// Consolidated rules used across all generators
// =============================================================================
export const CORE_LANGUAGE_RULES = `
<language_rules>
1. ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her", or gendered pronouns
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
5. Maintain anatomical consistency - NEVER combine male-specific and female-specific acts as alternatives
6. Keep concise - ideal length is 5-12 words per question
</language_rules>
`;
// =============================================================================
// SYSTEM MESSAGES
// Per-tone-level system prompts for question generation
// =============================================================================
export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
0: 'You are a content writer for a couples app. Generate everyday activities focused on bonding, communication, and fun. No romance or intimacy. Always respond with valid JSON only.',
1: 'You are a content writer for a couples app. Generate sweet, romantic activities that help couples connect emotionally. Keep it innocent and wholesome. Always respond with valid JSON only.',
2: 'You are a content writer for a couples app. Generate flirty, playful activities with light teasing and innuendo. Suggestive but not sexual. Always respond with valid JSON only.',
3: 'You are a content writer for a couples app. Generate intimate, sensual activities that imply passion without being explicit. Always respond with valid JSON only.',
4: 'You are a content writer for an adult couples app. Generate sexual activities with direct, natural language. Write like a sex-positive friend - not clinical. Always respond with valid JSON only.',
5: 'You are a content writer for an adult couples app. Generate wild, extreme, kinky activities that push boundaries. Use crude terms when they ARE the act but tasteful phrasing for general sex/oral. Never use clinical terms. Always respond with valid JSON only.',
1: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate gentle, emotionally focused activities for bonding.</role>
<constraint>NO sexual content whatsoever. Focus on emotional connection, quality time, and non-sexual touch.</constraint>
<style>Warm, caring, romantic. Activities should feel safe and nurturing.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate warm, romantic activities with affectionate touch.</role>
<constraint>Sensual but NOT explicit sex. Building anticipation without crossing into explicit territory.</constraint>
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate playful, sexually exploratory activities.</role>
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint>
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
4: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
<role>Generate steamy, explicit sexual activities with moderate adventure.</role>
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint>
<style>Passionate, explicit, adventurous. Bold but not extreme.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`,
5: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
<role>Generate intense, advanced sexual activities including BDSM and kinks.</role>
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint>
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
};
+65 -24
View File
@@ -3,7 +3,7 @@
// Generate category names and category ideas
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { GeneratedCategoryIdea } from '../types';
/**
@@ -11,19 +11,30 @@ import type { GeneratedCategoryIdea } from '../types';
*/
export async function generateCategory(): Promise<{ name: string; description: string; icon: string }> {
const openai = getOpenAI();
const prompt = `Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
const prompt = `<task>
Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
</task>
<app_context>
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
Categories organize collections of activity packs (date ideas, intimate experiences, adventures, challenges, etc.).
This is NOT a Q&A app - it's about discovering shared interests in activities.
</app_context>
Return a JSON object with:
- name: Category name (1-3 words, e.g., "Romance", "Adventure", "Date Nights")
- description: Brief description of what activity packs in this category contain (1 sentence)
- icon: A single emoji that represents this category
<examples>
Good category names: "Romance", "Adventure", "Date Nights", "Intimacy", "Bonding", "Exploration"
</examples>
Be creative and think of categories that help couples explore different types of activities together.`;
<output_format>
{
"name": string, // 1-3 words (e.g., "Romance", "Adventure", "Date Nights")
"description": string, // 1 sentence describing what activity packs contain
"icon": string // Single emoji representing this category
}
Be creative and think of categories that help couples explore different types of activities together.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -35,7 +46,7 @@ Be creative and think of categories that help couples explore different types of
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
@@ -67,32 +78,62 @@ export async function suggestCategories(
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your category suggestions.`
// Sanitize inspiration to prevent prompt injection
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '')
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
.trim()
: '';
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
? `\n\nIMPORTANT - DO NOT suggest any of these category names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
const inspirationSection = sanitizedInspiration
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
: '';
const prompt = `Here are the current categories in our couples' activity/intimacy app: ${existingList}.
const avoidSection = previousSuggestions && previousSuggestions.length > 0
? `\n<avoid_names>\n${previousSuggestions.join(', ')}\n</avoid_names>`
: '';
IMPORTANT - HOW THE APP WORKS:
- This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals
const prompt = `<task>
Suggest 5 NEW, UNIQUE category ideas for organizing activity packs in a couples' activity/intimacy app.
</task>
<existing_categories>
Current categories: ${existingList}
</existing_categories>
<app_context>
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
- Each partner independently swipes Like/Dislike/Maybe on activities
- When BOTH partners swipe positively, they "match" and can discuss the activity
- Categories organize collections of activity packs (date ideas, intimate experiences, adventures, etc.)
- This is NOT a Q&A app - it's about discovering shared interests in activities to do together
- This is NOT a Q&A app - it's about discovering shared interests in activities
</app_context>
${inspirationSection}
Suggest 5 NEW, UNIQUE category ideas that differ from the existing ones.
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
<content_guidelines>
${explicitInstruction}
${crudeLangInstruction}
</content_guidelines>
${avoidSection}
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Category name (1-3 words)
- description: Brief description of what activity packs in this category contain (1 sentence)
- icon: A single descriptive emoji
<category_themes>
Focus on diverse themes: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.
</category_themes>
Focus on diverse activity themes like: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.`;
<output_format>
{
"ideas": [
{
"name": string, // 1-3 words
"description": string, // 1 sentence, what activity packs contain
"icon": string // Single descriptive emoji
}
]
}
Generate 5 unique categories that differ from existing ones.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -104,7 +145,7 @@ Focus on diverse activity themes like: date ideas, adventures, intimate experien
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
+63 -31
View File
@@ -38,73 +38,91 @@ export async function evaluateQuestionPool(
generatorIndex: q.sourceGeneratorIndex,
}));
const prompt = `You are evaluating a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators.
const prompt = `<task>
Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators and select the best ones.
</task>
Your task is to:
<objectives>
1. Score each question individually (1-10 on each criterion)
2. Identify duplicates/semantically similar questions
3. Assess uniqueness relative to the pool
4. Consider pack theme fit
</objectives>
PACK CONTEXT:
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Tone Level: ${packContext.tone} (${toneDescription})
- Requested question count: ${requestedCount}
</pack_context>
<scoring_criteria>
${REVIEW_GUIDELINES}
ADDITIONAL CRITERIA FOR CHERRY-PICK:
5. UNIQUENESS (1-10):
- How unique is this question compared to others in the pool?
- 10: Completely unique concept
- 10: Completely unique concept in the pool
- 7-9: Somewhat similar theme but distinct execution
- 4-6: Similar to another question but different enough to keep
- 1-3: Near-duplicate of another question
</scoring_criteria>
DUPLICATE DETECTION:
<scoring_weights>
Your scores will be weighted as follows for final selection:
- Guideline Compliance: 25%
- Creativity: 20%
- Clarity: 20%
- Uniqueness: 20%
- Intensity Accuracy: 15%
Calibrate your scores knowing these weights. A low compliance score has the most impact.
</scoring_weights>
<duplicate_detection>
Two questions are duplicates if they:
- Describe the same core activity/action
- Differ only in minor phrasing (e.g., "Have sex outdoors" vs "Make love outside")
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside")
- Differ only in minor phrasing variations
- Would feel repetitive if both appeared in the same pack
For duplicates, keep the version with:
For duplicates, mark the WORSE version as duplicate and keep the version with:
1. Better phrasing/creativity
2. More appropriate intensity grading
3. Better partner_text (if applicable)
</duplicate_detection>
VERDICT RULES:
- SELECT: High quality (avg score >= 7), no major issues - should be included
- CONSIDER: Medium quality (avg score 5-7) - include if needed to fill count
- SKIP: Low quality (avg score < 5) OR is a duplicate of a better question
<verdict_rules>
- SELECT: Average score >= 7, no major issues - should be included
- CONSIDER: Average score 5-7 - include if needed to fill count
- SKIP: Average score < 5 OR is a duplicate of a better question
</verdict_rules>
QUESTIONS TO EVALUATE:
<questions_to_evaluate>
${JSON.stringify(questionsForEval, null, 2)}
</questions_to_evaluate>
Return a JSON object with:
<output_format>
{
"evaluations": [
{
"questionIndex": 0,
"overallScore": 82,
"questionIndex": number, // 0-based index in the questions array
"overallScore": number, // Weighted score 0-100
"scores": {
"guidelineCompliance": 8,
"creativity": 9,
"clarity": 8,
"intensityAccuracy": 8,
"uniqueness": 9
"guidelineCompliance": 1-10,
"creativity": 1-10,
"clarity": 1-10,
"intensityAccuracy": 1-10,
"uniqueness": 1-10
},
"isDuplicate": false,
"duplicateOf": null,
"issues": ["Minor issue 1"],
"verdict": "select"
"isDuplicate": boolean,
"duplicateOf": number|null, // Index of the better version if duplicate
"issues": string[], // List of issues found
"verdict": "select"|"consider"|"skip"
}
]
}
Evaluate ALL ${pooledQuestions.length} questions.`;
Evaluate ALL ${pooledQuestions.length} questions.
</output_format>`;
const response = await openai.chat.completions.create({
model: config.reviewerModel,
@@ -116,7 +134,7 @@ Evaluate ALL ${pooledQuestions.length} questions.`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: config.reviewerTemperature ?? 0.3,
});
const content = response.choices[0].message.content;
@@ -280,7 +298,16 @@ export async function performCherryPickSelection(
index: i,
verdict: 'pass' as const,
issues: [],
scores: { guidelineCompliance: 7, creativity: 7, clarity: 7, intensityAccuracy: 7 },
scores: {
guidelineCompliance: 7,
creativity: 7,
clarity: 7,
intensityAccuracy: 7,
anatomicalConsistency: 10,
partnerTextQuality: 10,
coupleTargeting: 10,
initiatorTargeting: 10,
},
};
}
@@ -298,6 +325,11 @@ export async function performCherryPickSelection(
creativity: evaluation.scores.creativity,
clarity: evaluation.scores.clarity,
intensityAccuracy: evaluation.scores.intensityAccuracy,
// Cherry-pick doesn't evaluate these, default to passing
anatomicalConsistency: 10,
partnerTextQuality: 10,
coupleTargeting: 10,
initiatorTargeting: 10,
},
};
});
+2 -1
View File
@@ -143,7 +143,8 @@ export async function generateQuestionsWithCouncil(
packDescription,
existingQuestions,
crudeLang,
inspiration
inspiration,
gen.temperature
);
const result = {
generatorIndex: index,
+80 -55
View File
@@ -3,7 +3,7 @@
// Generate pack names, descriptions, and pack ideas
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { GeneratedPack, GeneratedPackIdea } from '../types';
/**
@@ -26,48 +26,48 @@ export async function generatePack(
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your generation.`
// Sanitize inspiration to prevent prompt injection
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '')
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
.trim()
: '';
const avoidInstruction = previousNames && previousNames.length > 0
? `\n\nIMPORTANT - DO NOT generate any of these pack names (already suggested): ${previousNames.join(', ')}\nGenerate something completely different and fresh.`
const inspirationSection = sanitizedInspiration
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
: '';
const appContext = `This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
const avoidSection = previousNames && previousNames.length > 0
? `\n<avoid_names>\n${previousNames.join(', ')}\n</avoid_names>`
: '';
const prompt = `<task>
Generate a creative activity pack for a couples' intimacy/connection app${categoryName ? ` in the category "${categoryName}"` : ''}.
</task>
<app_context>
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
Packs contain collections of activities to try together (date ideas, intimate experiences, adventures, challenges, etc.).
This is NOT a Q&A app - it's about discovering shared interests in activities.`;
const prompt = categoryName
? `Generate a creative activity pack for a couples' intimacy/connection app in the category "${categoryName}".
${appContext}
The pack should contain a themed collection of activities couples can explore together.
This is NOT a Q&A app - it's about discovering shared interests in activities.
</app_context>
${inspirationSection}
<content_guidelines>
${explicitInstruction}
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
${crudeLangInstruction}
</content_guidelines>
${avoidSection}
Return a JSON object with:
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
<output_format>
{
"name": string, // Catchy pack name, 3-6 words, evokes activities/experiences
"description": string // Brief, enticing description, 1-2 sentences, activity-focused
}
Make it romantic, playful, and activity-focused.`
: `Generate a creative activity pack for a couples' intimacy/connection app.
${appContext}
The pack should contain a themed collection of activities couples can explore together.
${explicitInstruction}
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
Return a JSON object with:
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
Make it romantic, playful, and activity-focused.`;
Make it romantic, playful, and activity-focused.
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -79,7 +79,7 @@ This is NOT a Q&A app - it's about discovering shared interests in activities.`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.8,
temperature: getTemperature('generate', 0.8),
});
const content = response.choices[0].message.content;
@@ -112,42 +112,67 @@ export async function suggestPacks(
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your pack suggestions.`
// Sanitize inspiration to prevent prompt injection
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '')
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
.trim()
: '';
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
? `\n\nIMPORTANT - DO NOT suggest any of these pack names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
const inspirationSection = sanitizedInspiration
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
: '';
const prompt = `We are building activity packs for the category "${categoryName}" in a couples' intimacy app.
const avoidSection = previousSuggestions && previousSuggestions.length > 0
? `\n<avoid_names>\n${previousSuggestions.join(', ')}\n</avoid_names>`
: '';
IMPORTANT - HOW THE APP WORKS:
- This is an ACTIVITY-FOCUSED app, NOT a Q&A app
const prompt = `<task>
Suggest 5 NEW, UNIQUE activity pack ideas for the category "${categoryName}" in a couples' intimacy app.
</task>
<app_context>
This is an ACTIVITY-FOCUSED app, NOT a Q&A app.
- Packs contain ACTIVITY PROPOSALS (things couples can do together)
- Each partner independently swipes Like/Dislike/Maybe on activities
- When BOTH partners swipe positively on the same activity, they "match" and can discuss it
- Activities are things like: date ideas, intimate experiences, adventures, challenges, conversations to have, etc.
- This is NOT about asking each other questions - it's about discovering shared interests in activities
- When BOTH partners swipe positively, they "match" and can discuss it
- Activities include: date ideas, intimate experiences, adventures, challenges, conversations
- Focus on discovering shared interests in activities, not asking questions
</app_context>
${inspirationSection}
PACK THEMES should focus on:
<pack_themes>
Focus on:
- Collections of related activities couples might want to try
- Experiences to share together
- Things to do, not questions to ask
- Date ideas, adventures, intimate moments, challenges, bonding activities
</pack_themes>
Existing packs in this category: ${existingList}.
<existing_packs>
Current packs in this category: ${existingList}
</existing_packs>
Suggest 5 NEW, UNIQUE pack ideas that fit this category and differ from existing ones.
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
<content_guidelines>
${explicitInstruction}
${crudeLangInstruction}
</content_guidelines>
${avoidSection}
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Pack name (catchy, 3-6 words) - should evoke activities/experiences, not questions
- description: Brief description (1-2 sentences) focusing on the activities/experiences in the pack
- icon: An Ionicon name from this list: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
<output_format>
{
"ideas": [
{
"name": string, // Catchy, 3-6 words, evokes activities/experiences
"description": string, // 1-2 sentences, activity-focused
"icon": string // Ionicon: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
}
]
}
Make them engaging and specific to "${categoryName}".`;
Generate 5 unique pack ideas that differ from existing ones. Make them engaging and specific to "${categoryName}".
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('generate'),
@@ -159,7 +184,7 @@ Make them engaging and specific to "${categoryName}".`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
+91 -50
View File
@@ -3,8 +3,8 @@
// Generate questions for packs with various tone/intensity settings
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES } from '../config';
import { getOpenAI, getModel, getTemperature } from '../client';
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES, CORE_LANGUAGE_RULES } from '../config';
import type { GeneratedQuestion, ToneLevel } from '../types';
/**
@@ -20,53 +20,59 @@ function buildQuestionPrompt(
crudeLang: boolean = false,
inspiration?: string
): string {
const isClean = tone === 0;
const isExplicit = tone >= 4;
const crudeLangInstruction = crudeLang
? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.'
: '';
const inspirationInstruction = inspiration
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide the types of questions you generate.`
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
const sanitizedInspiration = inspiration
? inspiration
.replace(/<[^>]*>/g, '') // Remove XML-like tags
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '') // Remove authority words
.trim()
: '';
const intensityInstruction = isClean
? 'All questions should be intensity level 1 (non-physical activities).'
const inspirationInstruction = sanitizedInspiration
? `\n\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>\n\nIncorporate the user guidance above into your generation where appropriate. Use it to inform the theme and style of questions.`
: '';
const intensityInstruction = tone === 1
? 'All questions should be intensity level 1 (non-sexual bonding).'
: intensity
? `All questions should be at intensity level ${intensity}.`
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
: 'Vary the intensity levels appropriate for the tone.';
const toneInstruction = TONE_INSTRUCTIONS[tone];
// Examples based on tone level
const symmetricExamples = isClean
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
const symmetricExamples = tone <= 2
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".'
: isExplicit
? 'GOOD: "Fuck in a risky place", "Try a new position", "Sixty-nine together", "Watch porn and recreate a scene".'
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".';
? 'GOOD: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
const asymmetricExamples = isClean
const asymmetricExamples = tone <= 2
? `Examples:
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"
- text: "Give your partner a compliment" → partner_text: "Receive a compliment from your partner"`
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
: isExplicit
? `Examples:
- text: "Go down on your partner" → partner_text: "Have your partner go down on you"
- text: "Send your partner a nude" → partner_text: "Receive a nude from your partner"
- text: "Tie your partner up" → partner_text: "Get tied up by your partner"
- text: "Spank your partner" → partner_text: "Get spanked by your partner"`
- text: "Spank your partner" → partner_text: "Get spanked by your partner"
- text: "Use a toy on your partner" → partner_text: "Have a toy used on you"`
: `Examples:
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
- text: "Write your partner a love letter" → partner_text: "Receive a love letter from your partner"
- text: "Plan a surprise date for your partner" → partner_text: "Be surprised with a date by your partner"`;
- text: "Perform oral on your partner" → partner_text: "Receive oral from your partner"
- text: "Tease your partner" → partner_text: "Be teased by your partner"
- text: "Undress your partner" → partner_text: "Be undressed by your partner"`;
const explicitWarning = isClean
? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.'
const explicitWarning = tone === 1
? '\n\nCRITICAL: This is a GENTLE pack. Focus on emotional connection and non-sexual bonding. NO explicit sexual acts.'
: isExplicit
? ''
: '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.';
: '\n\nCRITICAL: Avoid extreme kinks or hardcore content unless specifically requested.';
const descriptionContext = packDescription
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
@@ -79,43 +85,77 @@ ${existingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
CRITICAL: Generate completely NEW and DIFFERENT questions. Do not repeat any of the above questions or create minor variations of them. Each new question must offer a genuinely distinct activity or experience.`
: '';
return `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}${existingQuestionsContext}
return `<task>
Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
</task>
${descriptionContext}${inspirationInstruction}${existingQuestionsContext}
<intensity_guide>
${INTENSITY_GUIDE_SHORT}
</intensity_guide>
<instructions>
${intensityInstruction}
${toneInstruction}${explicitWarning}${crudeLangInstruction}${inspirationInstruction}
${toneInstruction}${explicitWarning}${crudeLangInstruction}
</instructions>
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" for specific actions, NOT interview questions.
${CORE_LANGUAGE_RULES}
CRITICAL LANGUAGE RULES:
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her"
- BAD: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
- GOOD: "Send your partner a photo", "Spank your partner", "Give your partner..."
- The card reader is the DOER. Their partner is "your partner".
<examples>
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..."
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
The card reader is the DOER. Their partner is "your partner".
</examples>
For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other):
1. SYMMETRIC Activities (partner_text = null):
- Both partners do the same thing together.
<question_types>
1. SYMMETRIC Activities (partner_text = null, no inverse_pair_id):
- Both partners do the same thing together
- ${symmetricExamples}
2. ASYMMETRIC Actions (requires partner_text):
- One partner does something TO/FOR the other, or roles differ.
2. ASYMMETRIC Actions (requires partner_text AND inverse pair):
- One partner does something TO/FOR the other, or roles differ
- text = what the INITIATOR does
- partner_text = what the RECEIVER does/experiences
- ${asymmetricExamples}
- CRITICAL: Every asymmetric question MUST have its inverse created as a separate question
</question_types>
Required JSON structure:
- text: The proposal (DOER's perspective, uses "your partner")
- partner_text: For asymmetric only - RECEIVER's perspective
- intensity: 1-5 based on INTENSITY LEVELS above.
- requires_props: (optional) Array of items needed
- location_type: (optional) "home" | "public" | "outdoors" | "travel" | "anywhere"
- effort_level: (optional) "spontaneous" | "low" | "medium" | "planned"
<inverse_pairs>
For EVERY asymmetric question, you MUST create its inverse as a separate question.
Both questions in a pair should have the SAME inverse_pair_id (a unique string per pair).
Return a JSON object with a "questions" array.`;
Example pair (same inverse_pair_id "pair_1"):
Question 1 (primary):
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_pair_id: "pair_1"
Question 2 (inverse):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_pair_id: "pair_1"
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
Use simple pair IDs like "pair_1", "pair_2", etc.
Symmetric questions should have inverse_pair_id: null.
</inverse_pairs>
<output_format>
Return a JSON object with this exact structure:
{
"questions": [
{
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
"intensity": 1-5, // REQUIRED: must match the intensity level guidelines
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
}
]
}
</output_format>`;
}
/**
@@ -154,7 +194,7 @@ export async function generateQuestions(
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
@@ -177,7 +217,8 @@ export async function generateQuestionsWithModel(
packDescription: string | undefined,
existingQuestions: string[] | undefined,
crudeLang: boolean,
inspiration: string | undefined
inspiration: string | undefined,
temperature?: number
): Promise<GeneratedQuestion[]> {
const openai = getOpenAI();
@@ -202,7 +243,7 @@ export async function generateQuestionsWithModel(
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
temperature: temperature ?? getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
+4
View File
@@ -25,6 +25,8 @@ export type {
CherryPickResult,
TargetAnalysis,
TextAnalysis,
DeletionAnalysis,
PropsAnalysis,
ExtractedTopic,
TopicExtractionResult,
SelectionResult,
@@ -59,6 +61,8 @@ export { reviewGeneratedQuestions, selectBestGeneration } from './review';
// Analyzers
export { analyzeQuestionText } from './analyzers/text';
export { analyzeQuestionTargets } from './analyzers/targets';
export { analyzeQuestionDeletions } from './analyzers/deletions';
export { analyzeQuestionProps } from './analyzers/props';
// Tools
export { polishContent } from './tools/polish';
+119 -53
View File
@@ -39,47 +39,68 @@ export async function reviewGeneratedQuestions(
intensity: q.intensity,
location_type: q.location_type,
effort_level: q.effort_level,
// Include targeting for review (may not always be present)
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
}));
const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown';
const prompt = `You are a quality reviewer for a couples intimacy app. Review each generated question against our guidelines.
const prompt = `<task>
Review each generated question against our quality guidelines.
</task>
PACK CONTEXT:
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Tone Level: ${packContext.tone} (${toneDescription})
</pack_context>
<scoring_criteria>
${REVIEW_GUIDELINES}
</scoring_criteria>
VERDICT RULES:
<verdict_rules>
- PASS: All scores >= 7, no major issues
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
- REJECT: Any score < 5, or major violations (mixed anatomy, wrong intensity, severe guideline violation)
</verdict_rules>
QUESTIONS TO REVIEW:
<questions_to_review>
${JSON.stringify(questionsForReview, null, 2)}
</questions_to_review>
Return a JSON object with:
<output_format>
{
"reviews": [
{
"index": 0,
"index": number, // 0-based index of the question
"verdict": "pass"|"flag"|"reject",
"issues": ["Issue 1", "Issue 2"],
"suggestions": "Optional improvement suggestion",
"issues": string[], // List of issues found, empty if none
"suggestions": string|null, // Optional improvement suggestion
"scores": {
"guidelineCompliance": 8,
"creativity": 7,
"clarity": 9,
"intensityAccuracy": 8
}
"guidelineCompliance": 1-10, // Criterion 1
"creativity": 1-10, // Criterion 2
"clarity": 1-10, // Criterion 3
"intensityAccuracy": 1-10, // Criterion 4
"anatomicalConsistency": 1-10, // Criterion 5
"partnerTextQuality": 1-10, // Criterion 6 (10 if no partner_text needed)
"coupleTargeting": 1-10, // Criterion 7
"initiatorTargeting": 1-10 // Criterion 8 (10 if symmetric)
},
"intensitySuggestion": number|null, // Suggested intensity (1-5) if current seems wrong, null if correct
"targetingSuggestions": { // Only include if targeting needs changes
"suggestedCoupleTargets": string[]|null, // ["male+male", "female+male", "female+female"] or subset
"suggestedInitiator": string[]|null, // ["male", "female"] or null for any
"reason": string // Why targeting should change
}|null
}
]
}
Review ALL questions. Be thorough but fair - only flag/reject for genuine issues.`;
Review ALL questions against ALL 8 criteria. Be thorough but fair - only flag/reject for genuine issues.
</output_format>`;
const response = await openai.chat.completions.create({
model: config.reviewerModel,
@@ -91,7 +112,7 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: config.reviewerTemperature ?? 0.3,
});
const content = response.choices[0].message.content;
@@ -111,19 +132,31 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues
const flagged = reviews.filter(r => r.verdict === 'flag').length;
const rejected = reviews.filter(r => r.verdict === 'reject').length;
// Calculate overall quality as average of all scores
let totalScore = 0;
let scoreCount = 0;
// Calculate overall quality as weighted average of all 8 scores
// Weights: guidelineCompliance 20%, creativity 15%, clarity 15%, intensityAccuracy 15%,
// anatomicalConsistency 15%, partnerTextQuality 10%, coupleTargeting 5%, initiatorTargeting 5%
let totalWeightedScore = 0;
let totalWeight = 0;
const weights = {
guidelineCompliance: 0.20,
creativity: 0.15,
clarity: 0.15,
intensityAccuracy: 0.15,
anatomicalConsistency: 0.15,
partnerTextQuality: 0.10,
coupleTargeting: 0.05,
initiatorTargeting: 0.05,
};
for (const review of reviews) {
if (review.scores) {
totalScore += review.scores.guidelineCompliance || 0;
totalScore += review.scores.creativity || 0;
totalScore += review.scores.clarity || 0;
totalScore += review.scores.intensityAccuracy || 0;
scoreCount += 4;
for (const [key, weight] of Object.entries(weights)) {
const score = review.scores[key as keyof typeof review.scores] || 0;
totalWeightedScore += score * weight;
totalWeight += weight;
}
}
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
}
const overallQuality = totalWeight > 0 ? Math.round((totalWeightedScore / (totalWeight / Object.keys(weights).length)) * 10) / 10 : 0;
return {
reviews,
@@ -168,54 +201,77 @@ export async function selectBestGeneration(
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
})),
}));
const prompt = `You are a quality reviewer for a couples intimacy app. You have received ${candidates.length} different sets of generated questions from different AI models. Your task is to:
const prompt = `<task>
Compare ${candidates.length} different sets of generated questions from different AI models. Select the best set and review it in detail.
</task>
<objectives>
1. COMPARE all sets and SELECT the BEST one overall
2. REVIEW the selected set in detail
</objectives>
PACK CONTEXT:
<pack_context>
- Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
- Tone Level: ${packContext.tone} (${toneDescription})
</pack_context>
<scoring_criteria>
${REVIEW_GUIDELINES}
</scoring_criteria>
CANDIDATE SETS:
<candidate_sets>
${JSON.stringify(candidatesForReview, null, 2)}
</candidate_sets>
SELECTION CRITERIA (in order of importance):
1. Overall quality and creativity of questions
2. Variety and uniqueness within the set
3. Adherence to pack theme and tone
4. Proper intensity grading
5. Correct use of "your partner" language
6. Quality of partner_text for asymmetric questions
<selection_criteria>
Ranked by importance (approximate weights):
1. Overall quality and creativity of questions (30%)
2. Variety and uniqueness within the set (20%)
3. Adherence to pack theme and tone (20%)
4. Proper intensity grading (15%)
5. Correct use of "your partner" language (10%)
6. Quality of partner_text for asymmetric questions (5%)
</selection_criteria>
Return a JSON object with:
<output_format>
{
"selectedIndex": <0-based index of the best candidate set>,
"reasoning": "<brief explanation of why this set was chosen>",
"selectedIndex": number, // 0-based index of the best candidate set
"reasoning": string, // Brief explanation of why this set was chosen
"reviews": [
{
"index": <question index within selected set>,
"index": number, // Question index within selected set
"verdict": "pass"|"flag"|"reject",
"issues": ["Issue 1", "Issue 2"],
"suggestions": "Optional improvement suggestion",
"issues": string[],
"suggestions": string|null,
"scores": {
"guidelineCompliance": 1-10,
"creativity": 1-10,
"clarity": 1-10,
"intensityAccuracy": 1-10
}
"intensityAccuracy": 1-10,
"anatomicalConsistency": 1-10,
"partnerTextQuality": 1-10,
"coupleTargeting": 1-10,
"initiatorTargeting": 1-10
},
"intensitySuggestion": number|null,
"targetingSuggestions": {
"suggestedCoupleTargets": string[]|null,
"suggestedInitiator": string[]|null,
"reason": string
}|null
}
]
}
Select the best set and review ALL questions in that set.`;
Select the best set and review ALL questions in that set against ALL 8 criteria.
</output_format>`;
const response = await openai.chat.completions.create({
model: config.reviewerModel,
@@ -227,7 +283,7 @@ Select the best set and review ALL questions in that set.`;
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: config.reviewerTemperature ?? 0.3,
});
const content = response.choices[0].message.content;
@@ -244,23 +300,33 @@ Select the best set and review ALL questions in that set.`;
const reviews: QuestionReview[] = parsed.reviews || [];
const reasoning = parsed.reasoning || 'No reasoning provided';
// Calculate summary
// Calculate summary with weighted average of all 8 scores
const passed = reviews.filter(r => r.verdict === 'pass').length;
const flagged = reviews.filter(r => r.verdict === 'flag').length;
const rejected = reviews.filter(r => r.verdict === 'reject').length;
let totalScore = 0;
let scoreCount = 0;
let totalWeightedScore = 0;
let totalWeight = 0;
const weights = {
guidelineCompliance: 0.20,
creativity: 0.15,
clarity: 0.15,
intensityAccuracy: 0.15,
anatomicalConsistency: 0.15,
partnerTextQuality: 0.10,
coupleTargeting: 0.05,
initiatorTargeting: 0.05,
};
for (const review of reviews) {
if (review.scores) {
totalScore += review.scores.guidelineCompliance || 0;
totalScore += review.scores.creativity || 0;
totalScore += review.scores.clarity || 0;
totalScore += review.scores.intensityAccuracy || 0;
scoreCount += 4;
for (const [key, weight] of Object.entries(weights)) {
const score = review.scores[key as keyof typeof review.scores] || 0;
totalWeightedScore += score * weight;
totalWeight += weight;
}
}
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
}
const overallQuality = totalWeight > 0 ? Math.round((totalWeightedScore / (totalWeight / Object.keys(weights).length)) * 10) / 10 : 0;
return {
selectedIndex,
+21 -9
View File
@@ -3,7 +3,7 @@
// Polish and improve content text
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
type PolishType = 'question' | 'partner_text' | 'pack_name' | 'pack_description' | 'category_name';
@@ -64,20 +64,32 @@ export async function polishContent(
`;
}
const prompt = `Please polish, improve, and tidy up the following text, which is used as ${contextMap[type] || 'text in the app'}.
const prompt = `<task>
Polish, improve, and tidy up the following text used as ${contextMap[type] || 'text in the app'}.
</task>
Original text: "${text}"
<original_text>
"${text}"
</original_text>
<content_type>
${explicitInstruction}
</content_type>
<rules>
${additionalRules}
- Make it concise, engaging, and grammatically correct
- Maintain the original intent and meaning
- Do NOT change the core action - only improve phrasing
</rules>
${fewShotExamples}
Make it concise, engaging, and grammatically correct.
Maintain the original intent and meaning.
Return a JSON object with:
- polished: The improved text string`;
<output_format>
{
"polished": string // The improved text
}
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('polish'),
@@ -89,7 +101,7 @@ export async function polishContent(
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.7,
temperature: getTemperature('polish', 0.7),
});
const content = response.choices[0].message.content;
+35 -22
View File
@@ -3,7 +3,7 @@
// Extract topics from packs for categorization
// =============================================================================
import { getOpenAI, getModel } from '../client';
import { getOpenAI, getModel, getTemperature } from '../client';
import type { TopicExtractionResult } from '../types';
/**
@@ -20,34 +20,47 @@ export async function extractTopicsFromPack(
const existingTopicNames = existingTopics.map(t => t.name);
const existingTopicsJson = JSON.stringify(existingTopics);
const prompt = `Analyze the following question pack and extract relevant topics/kinks/interests that describe its content.
const prompt = `<task>
Analyze the question pack and extract relevant topics/kinks/interests that describe its content.
</task>
PACK NAME: "${packName}"
${packDescription ? `PACK DESCRIPTION: "${packDescription}"` : ''}
<pack_info>
Pack Name: "${packName}"
${packDescription ? `Pack Description: "${packDescription}"` : ''}
</pack_info>
QUESTIONS IN THIS PACK:
<questions>
${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
</questions>
EXISTING TOPICS IN THE SYSTEM (prefer matching these when appropriate):
${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
<existing_topics>
Available topics (prefer matching these): ${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
EXISTING TOPICS WITH IDs:
${existingTopicsJson}
Topic IDs: ${existingTopicsJson}
</existing_topics>
INSTRUCTIONS:
<instructions>
1. Identify 1-5 topics that best describe this pack's content
2. Topics should be specific kinks, interests, or themes (e.g., "Bondage", "Exhibitionism", "Voyeurism", "Role Play", "Sensory Play", "Oral", "Anal", "Communication", "Romance", "Adventure")
3. ALWAYS prefer matching an existing topic if one fits - use the EXACT name and include the existingTopicId
4. Only suggest a new topic if nothing in the existing list is appropriate
5. Topic names should be title case (e.g., "Bondage" not "bondage")
6. Keep topics broad enough to be reusable across packs
2. Topics should be specific kinks, interests, or themes
- Examples: Bondage, Exhibitionism, Voyeurism, Role Play, Sensory Play, Oral, Anal, Communication, Romance, Adventure
3. ALWAYS prefer matching an existing topic - use the EXACT name and include existingTopicId
4. Only suggest a new topic if nothing in existing list is appropriate
5. Topic names: Title Case (e.g., "Bondage" not "bondage")
6. Keep topics broad enough to be reusable across multiple packs
</instructions>
Return a JSON object with:
- topics: Array of objects with:
- name: The topic name (use exact existing name if matching)
- isNew: false if matching an existing topic, true if suggesting a new one
- existingTopicId: The ID of the existing topic if isNew is false (from the EXISTING TOPICS WITH IDs list)
- reasoning: Brief explanation of why these topics were chosen`;
<output_format>
{
"topics": [
{
"name": string, // Topic name (use exact existing name if matching)
"isNew": boolean, // false if matching existing, true if new
"existingTopicId": string|null // ID from existing topics if isNew is false
}
],
"reasoning": string // Brief explanation of why these topics were chosen
}
</output_format>`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
@@ -59,7 +72,7 @@ Return a JSON object with:
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
temperature: getTemperature('fix', 0.3),
});
const content = response.choices[0].message.content;
+40 -6
View File
@@ -12,6 +12,7 @@ export type ModelPurpose = 'generate' | 'fix' | 'polish' | 'describe_image';
export interface CouncilGenerator {
model: string;
temperature?: number;
}
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
@@ -20,6 +21,7 @@ export interface CouncilConfig {
enabled: boolean;
generators: CouncilGenerator[];
reviewerModel: string;
reviewerTemperature?: number;
selectionMode: CouncilSelectionMode;
cherryPickEnsureIntensityDistribution: boolean;
}
@@ -41,6 +43,9 @@ export interface GeneratedQuestion {
requires_props?: string[] | null;
location_type?: 'home' | 'public' | 'outdoors' | 'travel' | 'anywhere';
effort_level?: 'spontaneous' | 'low' | 'medium' | 'planned';
// Inverse pair tracking - questions with the same inverse_pair_id are inverses of each other
// First question in pair (lower array index) is primary, second gets inverse_of set
inverse_pair_id?: string | null;
// Cherry-pick provenance (only set in cherry-pick mode)
sourceGeneratorIndex?: number;
sourceGeneratorModel?: string;
@@ -62,16 +67,29 @@ export interface GeneratedPackIdea {
// REVIEW TYPES
// =============================================================================
export interface QuestionReviewScores {
guidelineCompliance: number;
creativity: number;
clarity: number;
intensityAccuracy: number;
anatomicalConsistency: number;
partnerTextQuality: number;
coupleTargeting: number;
initiatorTargeting: number;
}
export interface QuestionReview {
index: number;
verdict: 'pass' | 'flag' | 'reject';
issues: string[];
suggestions?: string;
scores: {
guidelineCompliance: number;
creativity: number;
clarity: number;
intensityAccuracy: number;
scores: QuestionReviewScores;
// Additional detailed feedback
intensitySuggestion?: number | null; // Suggested intensity if current seems wrong
targetingSuggestions?: {
suggestedCoupleTargets?: string[] | null;
suggestedInitiator?: string[] | null;
reason?: string;
};
}
@@ -188,6 +206,22 @@ export interface TextAnalysis {
suggested_text: string;
suggested_partner_text: string | null;
reason: string;
// Intensity suggestion (only if AI thinks current is wrong)
suggested_intensity?: number | null;
intensity_reason?: string | null;
}
export interface DeletionAnalysis {
id: string;
category: 'duplicate' | 'redundant' | 'off-tone' | 'unsafe' | 'too-vague' | 'broken' | 'off-topic';
reason: string;
duplicate_of_id?: string | null;
}
export interface PropsAnalysis {
id: string;
suggested_required_props: string[] | null;
reason: string;
}
export interface ExtractedTopic {
@@ -216,4 +250,4 @@ export interface SelectionResult {
// TONE LEVEL TYPE
// =============================================================================
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
export type ToneLevel = 1 | 2 | 3 | 4 | 5;
+436 -4
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
@@ -10,11 +11,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Textarea } from '@/components/ui/textarea';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield, X } from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { ModelCombobox } from '@/components/ModelCombobox';
type ListField = 'heuristic_whitelist' | 'heuristic_keyword_triggers';
const parseListValue = (value?: string | null): string[] => {
if (!value) return [];
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
};
const appendUniqueItem = (items: string[], value: string): string[] => {
const trimmed = value.trim();
if (!trimmed) return items;
const existing = new Set(items.map((item) => item.toLowerCase()));
if (existing.has(trimmed.toLowerCase())) return items;
return [...items, trimmed];
};
const removeItem = (items: string[], value: string): string[] => {
const target = value.toLowerCase();
return items.filter((item) => item.toLowerCase() !== target);
};
export function AiSettingsPage() {
const { hasPermission, isSuperAdmin } = useAuth();
const { config, loading, error, updateConfig, refetch } = useAiConfig();
@@ -24,6 +49,8 @@ export function AiSettingsPage() {
const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [whitelistInput, setWhitelistInput] = useState('');
const [keywordInput, setKeywordInput] = useState('');
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
@@ -39,18 +66,34 @@ export function AiSettingsPage() {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
default_temperature: config.default_temperature,
model_generate: config.model_generate || '',
temperature_generate: config.temperature_generate,
model_fix: config.model_fix || '',
temperature_fix: config.temperature_fix,
model_polish: config.model_polish || '',
temperature_polish: config.temperature_polish,
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_generators: generators,
council_reviewer_model: config.council_reviewer_model || '',
council_reviewer_temperature: config.council_reviewer_temperature,
council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_temperature: config.classifier_temperature,
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -61,6 +104,46 @@ export function AiSettingsPage() {
setHasChanges(true);
};
const whitelistItems = parseListValue(formData.heuristic_whitelist ?? '');
const keywordItems = parseListValue(formData.heuristic_keyword_triggers ?? '');
const updateListField = (field: ListField, items: string[]) => {
handleChange(field, items.length > 0 ? items.join('\n') : '');
};
const handleAddListItem = (
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = appendUniqueItem(current, value);
if (nextItems !== current) {
updateListField(field, nextItems);
}
if (value.trim()) {
clearValue('');
}
};
const handleRemoveListItem = (field: ListField, value: string) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = removeItem(current, value);
updateListField(field, nextItems);
};
const handleListKeyDown = (
event: KeyboardEvent<HTMLInputElement>,
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddListItem(field, value, clearValue);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
@@ -87,18 +170,34 @@ export function AiSettingsPage() {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
default_temperature: config.default_temperature,
model_generate: config.model_generate || '',
temperature_generate: config.temperature_generate,
model_fix: config.model_fix || '',
temperature_fix: config.temperature_fix,
model_polish: config.model_polish || '',
temperature_polish: config.temperature_polish,
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_generators: generators,
council_reviewer_model: config.council_reviewer_model || '',
council_reviewer_temperature: config.council_reviewer_temperature,
council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_temperature: config.classifier_temperature,
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -121,8 +220,8 @@ export function AiSettingsPage() {
handleChange('council_generators', newGenerators);
};
const updateGeneratorModel = (index: number, model: string) => {
const newGenerators = generators.map((g, i) => i === index ? { ...g, model } : g);
const updateGenerator = (index: number, updates: Partial<CouncilGenerator>) => {
const newGenerators = generators.map((g, i) => i === index ? { ...g, ...updates } : g);
handleChange('council_generators', newGenerators);
};
@@ -280,6 +379,7 @@ export function AiSettingsPage() {
/>
</div>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="classifier-model">Classifier Model</Label>
<ModelCombobox
@@ -291,6 +391,20 @@ export function AiSettingsPage() {
Model used for classification (must support vision if image moderation is needed)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-temperature">Temp</Label>
<Input
id="classifier-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.classifier_temperature ?? ''}
onChange={(e) => handleChange('classifier_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="1.0"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-prompt">System Prompt</Label>
@@ -305,6 +419,223 @@ export function AiSettingsPage() {
Instructions for the AI classifier.
</p>
</div>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Enable Heuristic Pre-Filter</Label>
<p className="text-xs text-muted-foreground">
Skip AI calls for low-risk messages using simple rules
</p>
</div>
<Switch
checked={formData.heuristics_enabled ?? false}
onCheckedChange={(checked) => handleChange('heuristics_enabled', checked)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="heuristic-min-length">Minimum Text Length</Label>
<Input
id="heuristic-min-length"
type="number"
min={0}
value={formData.heuristic_min_text_length ?? 12}
onChange={(e) =>
handleChange(
'heuristic_min_text_length',
e.target.value === '' ? null : Number(e.target.value)
)
}
/>
<p className="text-xs text-muted-foreground">
Skip AI for shorter messages without media
</p>
</div>
<div className="space-y-2">
<Label htmlFor="heuristic-whitelist-max">Whitelist Max Length</Label>
<Input
id="heuristic-whitelist-max"
type="number"
min={0}
value={formData.heuristic_whitelist_max_length ?? 30}
onChange={(e) =>
handleChange(
'heuristic_whitelist_max_length',
e.target.value === '' ? null : Number(e.target.value)
)
}
/>
<p className="text-xs text-muted-foreground">
Only apply whitelist to messages below this length
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Skip If No Letters/Numbers</Label>
<p className="text-xs text-muted-foreground">
Treat emoji-only or punctuation-only messages as safe
</p>
</div>
<Switch
checked={formData.heuristic_skip_if_no_alnum ?? true}
onCheckedChange={(checked) => handleChange('heuristic_skip_if_no_alnum', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Skip Media Without Text</Label>
<p className="text-xs text-muted-foreground">
Skip AI when only media is present
</p>
</div>
<Switch
checked={formData.heuristic_skip_media_without_text ?? false}
onCheckedChange={(checked) => handleChange('heuristic_skip_media_without_text', checked)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Use Default Whitelist</Label>
<p className="text-xs text-muted-foreground">
Include built-in common phrases
</p>
</div>
<Switch
checked={formData.heuristic_use_default_whitelist ?? true}
onCheckedChange={(checked) => handleChange('heuristic_use_default_whitelist', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Use Default Keywords</Label>
<p className="text-xs text-muted-foreground">
Include built-in safety keywords
</p>
</div>
<Switch
checked={formData.heuristic_use_default_keywords ?? true}
onCheckedChange={(checked) => handleChange('heuristic_use_default_keywords', checked)}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Record Heuristic Reason</Label>
<p className="text-xs text-muted-foreground">
Store skip reason in flag_reason for debugging
</p>
</div>
<Switch
checked={formData.heuristic_record_reason ?? false}
onCheckedChange={(checked) => handleChange('heuristic_record_reason', checked)}
/>
</div>
<div className="space-y-2">
<Label>Custom Whitelist</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={whitelistInput}
onChange={(e) => setWhitelistInput(e.target.value)}
onKeyDown={(event) =>
handleListKeyDown(event, 'heuristic_whitelist', whitelistInput, setWhitelistInput)
}
placeholder="Add phrase"
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleAddListItem('heuristic_whitelist', whitelistInput, setWhitelistInput)
}
disabled={!whitelistInput.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{whitelistItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{whitelistItems.map((item) => (
<Badge key={item} variant="secondary" className="flex items-center gap-1">
<span className="font-mono text-xs">{item}</span>
<button
type="button"
onClick={() => handleRemoveListItem('heuristic_whitelist', item)}
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${item}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No phrases added yet.</p>
)}
<p className="text-xs text-muted-foreground">
Messages matching these phrases are marked safe
</p>
</div>
<div className="space-y-2">
<Label>Keyword Triggers</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(event) =>
handleListKeyDown(event, 'heuristic_keyword_triggers', keywordInput, setKeywordInput)
}
placeholder="Add keyword"
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleAddListItem('heuristic_keyword_triggers', keywordInput, setKeywordInput)
}
disabled={!keywordInput.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{keywordItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{keywordItems.map((item) => (
<Badge key={item} variant="secondary" className="flex items-center gap-1">
<span className="font-mono text-xs">{item}</span>
<button
type="button"
onClick={() => handleRemoveListItem('heuristic_keyword_triggers', item)}
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${item}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No keywords added yet.</p>
)}
<p className="text-xs text-muted-foreground">
If any term appears, the message will be sent to AI
</p>
</div>
</div>
</CardContent>
</Card>
@@ -320,6 +651,7 @@ export function AiSettingsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="default-model">Default Model</Label>
<ModelCombobox
@@ -331,6 +663,20 @@ export function AiSettingsPage() {
Fallback model when no specific model is configured
</p>
</div>
<div className="space-y-2">
<Label htmlFor="default-temperature">Temp</Label>
<Input
id="default-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.default_temperature ?? ''}
onChange={(e) => handleChange('default_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.7"
/>
</div>
</div>
<Separator />
@@ -359,6 +705,7 @@ export function AiSettingsPage() {
{!formData.council_enabled ? (
/* Single generator mode */
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="model-generate">Generation Model</Label>
<ModelCombobox
@@ -370,6 +717,20 @@ export function AiSettingsPage() {
For questions, category ideas, and pack ideas
</p>
</div>
<div className="space-y-2">
<Label htmlFor="generate-temperature">Temp</Label>
<Input
id="generate-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_generate ?? ''}
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.9"
/>
</div>
</div>
) : (
/* Council mode - multiple generators + reviewer */
<div className="space-y-4 pl-4 border-l-2 border-primary/30">
@@ -394,10 +755,21 @@ export function AiSettingsPage() {
</div>
<ModelCombobox
value={gen.model}
onChange={(value) => updateGeneratorModel(index, value)}
onChange={(value) => updateGenerator(index, { model: value })}
placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Select model..."}
className="flex-1"
/>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={gen.temperature ?? ''}
onChange={(e) => updateGenerator(index, { temperature: e.target.value === '' ? undefined : Number(e.target.value) })}
placeholder="0.9"
className="w-[80px]"
aria-label={`Generator ${index + 1} temperature`}
/>
<Button
type="button"
variant="ghost"
@@ -413,6 +785,7 @@ export function AiSettingsPage() {
</div>
{/* Reviewer */}
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
<ModelCombobox
@@ -424,6 +797,20 @@ export function AiSettingsPage() {
Reviews outputs and selects the best
</p>
</div>
<div className="space-y-2">
<Label htmlFor="reviewer-temperature" className="text-sm">Temp</Label>
<Input
id="reviewer-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.council_reviewer_temperature ?? ''}
onChange={(e) => handleChange('council_reviewer_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.3"
/>
</div>
</div>
{/* Selection Mode */}
<div className="space-y-2">
@@ -490,6 +877,7 @@ export function AiSettingsPage() {
{/* Ideas Model - shown when council mode is ON (since council only applies to questions) */}
{formData.council_enabled && (
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="model-generate">Ideas Model</Label>
<ModelCombobox
@@ -501,6 +889,20 @@ export function AiSettingsPage() {
For category ideas and pack ideas (council mode only applies to questions)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ideas-temperature">Temp</Label>
<Input
id="ideas-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_generate ?? ''}
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.9"
/>
</div>
</div>
)}
</div>
@@ -508,6 +910,7 @@ export function AiSettingsPage() {
{/* Other models - always visible */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid grid-cols-[1fr,100px] gap-2">
<div className="space-y-2">
<Label htmlFor="model-fix">Analysis Model</Label>
<ModelCombobox
@@ -519,7 +922,22 @@ export function AiSettingsPage() {
For analyzing & fixing content
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fix-temperature">Temp</Label>
<Input
id="fix-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_fix ?? ''}
onChange={(e) => handleChange('temperature_fix', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.5"
/>
</div>
</div>
<div className="grid grid-cols-[1fr,100px] gap-2">
<div className="space-y-2">
<Label htmlFor="model-polish">Polish Model</Label>
<ModelCombobox
@@ -531,6 +949,20 @@ export function AiSettingsPage() {
For polishing & improving text
</p>
</div>
<div className="space-y-2">
<Label htmlFor="polish-temperature">Temp</Label>
<Input
id="polish-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_polish ?? ''}
onChange={(e) => handleChange('temperature_polish', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.7"
/>
</div>
</div>
</div>
</CardContent>
+39
View File
@@ -28,6 +28,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -59,6 +60,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -176,6 +178,43 @@ export function AppSettingsPage() {
</div>
</CardContent>
</Card>
{/* Daily Response Limit */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Daily Response Limit (Free Users)
</CardTitle>
<CardDescription>
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dailyLimit">Daily Response Limit</Label>
<Input
id="dailyLimit"
type="number"
min="0"
max="1000"
value={formData.daily_response_limit ?? 0}
onChange={(e) => handleChange('daily_response_limit', parseInt(e.target.value) || 0)}
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
Maximum number of questions a non-premium user can answer per UTC day.
Premium users (via personal or partner subscription) bypass this limit.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely.
</p>
<p className="text-xs text-muted-foreground">
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
+36 -8
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
const fetchLogs = async () => {
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data, error, count } = await supabase
.from('audit_logs')
.select('*')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.limit(100);
.range(from, to);
if (error) throw error;
setLogs(data || []);
setTotalCount(count || 0);
} catch (error) {
console.error('Failed to load audit logs:', error);
} finally {
setLoading(false);
}
};
}, [page, pageSize]);
useEffect(() => {
fetchLogs();
}, []);
}, [fetchLogs]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
if (loading) {
return (
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
</TableBody>
</Table>
</div>
{logs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+38 -1
View File
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -87,6 +89,9 @@ export function FeedbackPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Detail Dialog State
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
@@ -140,6 +145,11 @@ export function FeedbackPage() {
fetchFeedback();
}, [fetchFeedback]);
useEffect(() => {
setPage(1);
}, [search, typeFilter, statusFilter]);
const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item);
setEditStatus(item.status);
@@ -187,6 +197,20 @@ export function FeedbackPage() {
);
});
const totalCount = filteredFeedback.length;
const paginatedFeedback = filteredFeedback.slice(
(page - 1) * pageSize,
page * pageSize
);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
const getTypeBadge = (type: FeedbackType) => {
const config = feedbackTypeConfig[type];
return (
@@ -295,7 +319,7 @@ export function FeedbackPage() {
</TableCell>
</TableRow>
) : (
filteredFeedback.map((item) => (
paginatedFeedback.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
<TableCell>{getTypeBadge(item.type)}</TableCell>
<TableCell>
@@ -329,6 +353,19 @@ export function FeedbackPage() {
</Table>
</div>
{filteredFeedback.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
+72 -15
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { PaginationControls } from '@/components/ui/pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
// AI Flagged state
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
const [aiLoading, setAiLoading] = useState(true);
const [aiPage, setAiPage] = useState(1);
const [aiPageSize, setAiPageSize] = useState(25);
const [aiTotal, setAiTotal] = useState(0);
// User Reports state
const [userReports, setUserReports] = useState<UserReport[]>([]);
const [reportsLoading, setReportsLoading] = useState(true);
const [reportsPage, setReportsPage] = useState(1);
const [reportsPageSize, setReportsPageSize] = useState(25);
const [reportsTotal, setReportsTotal] = useState(0);
// Shared state
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
const [describingInline, setDescribingInline] = useState<string | null>(null);
// Fetch AI flagged messages
const fetchAiMessages = async () => {
const fetchAiMessages = useCallback(async () => {
setAiLoading(true);
try {
const { data, error } = await supabase
const from = (aiPage - 1) * aiPageSize;
const to = from + aiPageSize - 1;
const { data, error, count } = await supabase
.from('messages')
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type')
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
.eq('moderation_status', 'flagged')
.order('created_at', { ascending: false });
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
setAiMessages(data || []);
setAiTotal(count || 0);
} catch (error) {
console.error('Failed to load flagged messages:', error);
toast.error('Failed to load messages');
} finally {
setAiLoading(false);
}
};
}, [aiPage, aiPageSize]);
// Fetch user reports
const fetchUserReports = async () => {
const fetchUserReports = useCallback(async () => {
setReportsLoading(true);
try {
const { data, error } = await supabase
const from = (reportsPage - 1) * reportsPageSize;
const to = from + reportsPageSize - 1;
const { data, error, count } = await supabase
.from('message_reports')
.select(`
id,
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
created_at,
message:messages(id, user_id, match_id, content, media_path, media_type),
reporter_profile:profiles!reporter_id(name, email)
`)
`, { count: 'exact' })
.eq('status', 'pending')
.order('created_at', { ascending: false });
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
setUserReports((data || []) as unknown as UserReport[]);
setReportsTotal(count || 0);
} catch (error) {
console.error('Failed to load user reports:', error);
toast.error('Failed to load reports');
} finally {
setReportsLoading(false);
}
};
}, [reportsPage, reportsPageSize]);
useEffect(() => {
fetchAiMessages();
fetchUserReports();
}, []);
}, [fetchAiMessages, fetchUserReports]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(aiTotal / aiPageSize));
if (aiPage > totalPages) {
setAiPage(totalPages);
}
}, [aiPage, aiPageSize, aiTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(reportsTotal / reportsPageSize));
if (reportsPage > totalPages) {
setReportsPage(totalPages);
}
}, [reportsPage, reportsPageSize, reportsTotal]);
// Custom media fetcher because supabase.functions.invoke is JSON-centric
const fetchDecryptedMedia = async (messageId: string) => {
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
if (error) throw error;
toast.success('Message marked as safe');
setAiMessages(prev => prev.filter(m => m.id !== messageId));
if (selectedMessage?.id === messageId) setSelectedMessage(null);
fetchAiMessages();
} catch (error) {
toast.error('Failed to update status');
} finally {
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
if (error) throw error;
toast.success('Report dismissed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) {
toast.error('Failed to dismiss report');
} finally {
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
if (error) throw error;
toast.success('Report marked as reviewed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) {
toast.error('Failed to update report');
} finally {
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
</TableBody>
</Table>
</Card>
{aiTotal > 0 && (
<PaginationControls
page={aiPage}
pageSize={aiPageSize}
totalCount={aiTotal}
onPageChange={setAiPage}
onPageSizeChange={(size) => {
setAiPage(1);
setAiPageSize(size);
}}
/>
)}
</TabsContent>
<TabsContent value="user-reports" className="space-y-4">
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
</TableBody>
</Table>
</Card>
{reportsTotal > 0 && (
<PaginationControls
page={reportsPage}
pageSize={reportsPageSize}
totalCount={reportsTotal}
onPageChange={setReportsPage}
onPageSizeChange={(size) => {
setReportsPage(1);
setReportsPageSize(size);
}}
/>
)}
</TabsContent>
</Tabs>
+101 -23
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Switch } from '@/components/ui/switch';
import {
Table,
@@ -64,6 +67,10 @@ export function RedemptionCodesPage() {
const [codes, setCodes] = useState<RedemptionCode[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Add Code State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -79,41 +86,60 @@ export function RedemptionCodesPage() {
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
const [redemptionsPage, setRedemptionsPage] = useState(1);
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
// Initial load
useEffect(() => {
fetchCodes();
}, []);
const fetchCodes = async () => {
const fetchCodes = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const trimmedSearch = search.trim();
let query = supabase
.from('redemption_codes')
.select('*')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false });
if (trimmedSearch) {
query = query.or(`code.ilike.%${trimmedSearch}%,description.ilike.%${trimmedSearch}%`);
}
const { data, error, count } = await query.range(from, to);
if (error) throw error;
setCodes(data || []);
setTotalCount(count || 0);
} catch (error: any) {
console.error('Failed to load codes:', error);
toast.error("Failed to load redemption codes");
} finally {
setLoading(false);
}
};
}, [page, pageSize, search]);
const fetchRedemptions = async (codeId: string) => {
const fetchRedemptions = useCallback(async (codeId: string) => {
setLoadingRedemptions(true);
try {
const { data: redemptionData, error: redemptionError } = await supabase
const from = (redemptionsPage - 1) * redemptionsPageSize;
const to = from + redemptionsPageSize - 1;
const { data: redemptionData, error: redemptionError, count } = await supabase
.from('code_redemptions')
.select('id, user_id, redeemed_at')
.select('id, user_id, redeemed_at', { count: 'exact' })
.eq('code_id', codeId)
.order('redeemed_at', { ascending: false });
.order('redeemed_at', { ascending: false })
.range(from, to);
if (redemptionError) throw redemptionError;
setRedemptionsTotal(count || 0);
if (redemptionData && redemptionData.length > 0) {
const userIds = redemptionData.map(r => r.user_id);
const { data: profileData } = await supabase
@@ -135,7 +161,36 @@ export function RedemptionCodesPage() {
} finally {
setLoadingRedemptions(false);
}
};
}, [redemptionsPage, redemptionsPageSize]);
useEffect(() => {
fetchCodes();
}, [fetchCodes]);
useEffect(() => {
setPage(1);
}, [search]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
if (viewingCode) {
fetchRedemptions(viewingCode.id);
}
}, [viewingCode, fetchRedemptions]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(redemptionsTotal / redemptionsPageSize));
if (redemptionsPage > totalPages) {
setRedemptionsPage(totalPages);
}
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
const handleAddCode = async () => {
if (!formData.code.trim()) {
@@ -226,17 +281,12 @@ export function RedemptionCodesPage() {
const handleViewRedemptions = (code: RedemptionCode) => {
setViewingCode(code);
fetchRedemptions(code.id);
setRedemptionsPage(1);
};
const filteredCodes = codes.filter(code => {
if (!search) return true;
const query = search.toLowerCase();
return (
code.code.toLowerCase().includes(query) ||
code.description?.toLowerCase().includes(query)
);
});
const filteredCodes = codes;
const getStatusBadge = (code: RedemptionCode) => {
if (!code.is_active) {
@@ -440,6 +490,20 @@ export function RedemptionCodesPage() {
</Table>
</div>
{filteredCodes.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* View Redemptions Dialog */}
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
<DialogContent className="sm:max-w-[500px]">
@@ -486,7 +550,21 @@ export function RedemptionCodesPage() {
</Table>
)}
</div>
{redemptionsTotal > 0 && (
<PaginationControls
page={redemptionsPage}
pageSize={redemptionsPageSize}
totalCount={redemptionsTotal}
onPageChange={setRedemptionsPage}
onPageSizeChange={(size) => {
setRedemptionsPage(1);
setRedemptionsPageSize(size);
}}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
</DialogFooter>
</DialogContent>
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
@@ -121,6 +122,18 @@ export function UserActivityPage() {
const [matches, setMatches] = useState<MatchActivity[]>([]);
const [messages, setMessages] = useState<MessageActivity[]>([]);
const [signups, setSignups] = useState<SignupActivity[]>([]);
const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(25);
const [responsesTotal, setResponsesTotal] = useState(0);
const [matchesPage, setMatchesPage] = useState(1);
const [matchesPageSize, setMatchesPageSize] = useState(25);
const [matchesTotal, setMatchesTotal] = useState(0);
const [messagesPage, setMessagesPage] = useState(1);
const [messagesPageSize, setMessagesPageSize] = useState(25);
const [messagesTotal, setMessagesTotal] = useState(0);
const [signupsPage, setSignupsPage] = useState(1);
const [signupsPageSize, setSignupsPageSize] = useState(25);
const [signupsTotal, setSignupsTotal] = useState(0);
const [loading, setLoading] = useState({
responses: true,
matches: true,
@@ -132,7 +145,10 @@ export function UserActivityPage() {
const fetchResponses = useCallback(async () => {
setLoading(prev => ({ ...prev, responses: true }));
try {
const { data, error } = await supabase
const from = (responsesPage - 1) * responsesPageSize;
const to = from + responsesPageSize - 1;
const { data, error, count } = await supabase
.from('responses')
.select(`
id, user_id, answer, created_at,
@@ -141,12 +157,14 @@ export function UserActivityPage() {
id, text,
pack:question_packs(name)
)
`)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (error) throw error;
setResponsesTotal(count || 0);
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id,
@@ -167,23 +185,28 @@ export function UserActivityPage() {
} finally {
setLoading(prev => ({ ...prev, responses: false }));
}
}, []);
}, [responsesPage, responsesPageSize]);
// Fetch matches with couple profiles
const fetchMatches = useCallback(async () => {
setLoading(prev => ({ ...prev, matches: true }));
try {
const { data: matchesData, error: matchesError } = await supabase
const from = (matchesPage - 1) * matchesPageSize;
const to = from + matchesPageSize - 1;
const { data: matchesData, error: matchesError, count } = await supabase
.from('matches')
.select(`
id, couple_id, match_type, is_new, created_at,
question:questions(id, text)
`)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (matchesError) throw matchesError;
setMatchesTotal(count || 0);
// Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) {
@@ -223,14 +246,17 @@ export function UserActivityPage() {
} finally {
setLoading(prev => ({ ...prev, matches: false }));
}
}, []);
}, [matchesPage, matchesPageSize]);
// Fetch messages
const fetchMessages = useCallback(async () => {
setLoading(prev => ({ ...prev, messages: true }));
try {
const from = (messagesPage - 1) * messagesPageSize;
const to = from + messagesPageSize - 1;
// Fetch messages with match data
const { data: messagesData, error: messagesError } = await supabase
const { data: messagesData, error: messagesError, count } = await supabase
.from('messages')
.select(`
id, user_id, content, media_path, media_type, created_at,
@@ -238,12 +264,14 @@ export function UserActivityPage() {
id,
question:questions(text)
)
`)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (messagesError) throw messagesError;
setMessagesTotal(count || 0);
// Get unique user IDs and fetch profiles
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
@@ -283,27 +311,31 @@ export function UserActivityPage() {
} finally {
setLoading(prev => ({ ...prev, messages: false }));
}
}, []);
}, [messagesPage, messagesPageSize]);
// Fetch signups
const fetchSignups = useCallback(async () => {
setLoading(prev => ({ ...prev, signups: true }));
try {
const { data, error } = await supabase
const from = (signupsPage - 1) * signupsPageSize;
const to = from + signupsPageSize - 1;
const { data, error, count } = await supabase
.from('profiles')
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id')
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (error) throw error;
setSignups(data || []);
setSignupsTotal(count || 0);
} catch (error) {
console.error('Failed to load signups:', error);
toast.error("Failed to load signups");
} finally {
setLoading(prev => ({ ...prev, signups: false }));
}
}, []);
}, [signupsPage, signupsPageSize]);
// Real-time subscriptions
const { status: responsesStatus } = useRealtimeSubscription({
@@ -338,6 +370,34 @@ export function UserActivityPage() {
fetchSignups();
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
if (responsesPage > totalPages) {
setResponsesPage(totalPages);
}
}, [responsesPage, responsesPageSize, responsesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
if (matchesPage > totalPages) {
setMatchesPage(totalPages);
}
}, [matchesPage, matchesPageSize, matchesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(messagesTotal / messagesPageSize));
if (messagesPage > totalPages) {
setMessagesPage(totalPages);
}
}, [messagesPage, messagesPageSize, messagesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(signupsTotal / signupsPageSize));
if (signupsPage > totalPages) {
setSignupsPage(totalPages);
}
}, [signupsPage, signupsPageSize, signupsTotal]);
// Get current realtime status based on active tab
const getCurrentRealtimeStatus = () => {
switch (activeTab) {
@@ -461,6 +521,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{responsesTotal > 0 && (
<PaginationControls
page={responsesPage}
pageSize={responsesPageSize}
totalCount={responsesTotal}
onPageChange={setResponsesPage}
onPageSizeChange={(size) => {
setResponsesPage(1);
setResponsesPageSize(size);
}}
/>
)}
</TabsContent>
{/* Matches Tab */}
@@ -535,6 +608,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{matchesTotal > 0 && (
<PaginationControls
page={matchesPage}
pageSize={matchesPageSize}
totalCount={matchesTotal}
onPageChange={setMatchesPage}
onPageSizeChange={(size) => {
setMatchesPage(1);
setMatchesPageSize(size);
}}
/>
)}
</TabsContent>
{/* Messages Tab */}
@@ -593,6 +679,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{messagesTotal > 0 && (
<PaginationControls
page={messagesPage}
pageSize={messagesPageSize}
totalCount={messagesTotal}
onPageChange={setMessagesPage}
onPageSizeChange={(size) => {
setMessagesPage(1);
setMessagesPageSize(size);
}}
/>
)}
</TabsContent>
{/* Signups Tab */}
@@ -666,6 +765,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{signupsTotal > 0 && (
<PaginationControls
page={signupsPage}
pageSize={signupsPageSize}
totalCount={signupsTotal}
onPageChange={setSignupsPage}
onPageSizeChange={(size) => {
setSignupsPage(1);
setSignupsPageSize(size);
}}
/>
)}
</TabsContent>
</Tabs>
</div>
+34 -1
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -58,6 +59,8 @@ export function AdminsPage() {
const [admins, setAdmins] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Add Admin State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -280,6 +283,23 @@ export function AdminsPage() {
);
});
const totalCount = filteredAdmins.length;
const paginatedAdmins = filteredAdmins.slice(
(page - 1) * pageSize,
page * pageSize
);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
}, [search]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -509,7 +529,7 @@ export function AdminsPage() {
</TableCell>
</TableRow>
) : (
filteredAdmins.map((admin) => (
paginatedAdmins.map((admin) => (
<TableRow key={admin.user_id}>
<TableCell>
<div className="flex items-center gap-3">
@@ -590,6 +610,19 @@ export function AdminsPage() {
</Table>
</div>
{filteredAdmins.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Edit Permissions Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
+262 -96
View File
@@ -3,13 +3,16 @@ import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Dialog,
DialogContent,
@@ -19,10 +22,27 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// =============================================================================
// Types
@@ -35,6 +55,7 @@ interface Category {
icon: string | null;
sort_order: number | null;
created_at: string | null;
is_public: boolean;
pack_count?: number;
}
@@ -42,6 +63,109 @@ interface CategoryFormData {
name: string;
description: string;
icon: string;
is_public: boolean;
}
// =============================================================================
// Sortable Card Component
// =============================================================================
interface SortableCategoryCardProps {
category: Category;
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
onToggleVisibility: (category: Category) => void;
}
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: category.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
};
return (
<Card
ref={setNodeRef}
style={style}
className="group flex flex-col hover:border-primary/50 transition-colors"
>
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity touch-none"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<div className="rounded-lg bg-primary/10 p-2.5">
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
{category.is_public ? 'Public' : 'Hidden'}
</span>
<Switch
checked={category.is_public}
onCheckedChange={() => onToggleVisibility(category)}
className="scale-75 origin-right"
/>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4">
<div className="space-y-1">
<CardTitle className="text-lg">{category.name}</CardTitle>
<CardDescription className="line-clamp-2 h-10">
{category.description || 'No description'}
</CardDescription>
</div>
<div className="flex items-center justify-between mt-auto">
<Badge variant="secondary" className="font-normal">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</Badge>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(category)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(category)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" className="w-full">
View Packs
</Button>
</Link>
</CardContent>
</Card>
);
}
// =============================================================================
@@ -53,14 +177,18 @@ export function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the new hook
const form = useEntityForm<CategoryFormData, Category>(
{ name: '', description: '', icon: 'bookmark-outline' },
{ name: '', description: '', icon: 'bookmark-outline', is_public: true },
(category) => ({
name: category.name,
description: category.description || '',
icon: category.icon || 'bookmark-outline',
is_public: category.is_public ?? true,
})
);
@@ -71,25 +199,34 @@ export function CategoriesPage() {
const fetchCategories = useCallback(async () => {
setLoading(true);
try {
// Fetch categories with pack count
const { data: cats, error } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data: cats, error, count } = await supabase
.from('categories')
.select('*')
.order('sort_order', { ascending: true });
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (error) throw error;
// Fetch pack counts per category
setTotalCount(count || 0);
const categoryIds = (cats || []).map(c => c.id);
const packCounts: Record<string, number> = {};
if (categoryIds.length > 0) {
const { data: packs } = await supabase
.from('question_packs')
.select('category_id');
.select('category_id')
.in('category_id', categoryIds);
const packCounts: Record<string, number> = {};
packs?.forEach(p => {
if (p.category_id) {
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
}
});
}
setCategories(
(cats || []).map(c => ({
@@ -103,12 +240,19 @@ export function CategoriesPage() {
} finally {
setLoading(false);
}
}, []);
}, [page, pageSize]);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// =============================================================================
// Handlers
// =============================================================================
@@ -119,6 +263,7 @@ export function CategoriesPage() {
name: idea.name,
description: idea.description || '',
icon: idea.icon || 'bookmark-outline',
is_public: true,
});
};
@@ -134,6 +279,7 @@ export function CategoriesPage() {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_public: form.formData.is_public,
};
if (form.editingItem) {
@@ -143,7 +289,7 @@ export function CategoriesPage() {
} else {
const { error } = await auditedSupabase.insert('categories', {
...data,
sort_order: categories.length,
sort_order: totalCount,
});
if (error) throw error;
toast.success('Category created');
@@ -175,31 +321,70 @@ export function CategoriesPage() {
}
};
const handleMove = async (category: Category, direction: 'up' | 'down') => {
const currentIndex = categories.findIndex(c => c.id === category.id);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= categories.length) return;
const targetCategory = categories[targetIndex];
const handleToggleVisibility = async (category: Category) => {
try {
const currentOrder = category.sort_order ?? currentIndex;
const targetOrder = targetCategory.sort_order ?? targetIndex;
await Promise.all([
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
]);
// Optimistic update
const newCategories = [...categories];
[newCategories[currentIndex], newCategories[targetIndex]] = [newCategories[targetIndex], newCategories[currentIndex]];
setCategories(newCategories);
setCategories(categories.map(c =>
c.id === category.id ? { ...c, is_public: !c.is_public } : c
));
const { error } = await auditedSupabase.update('categories', category.id, {
is_public: !category.is_public
});
if (error) throw error;
toast.success(category.is_public ? 'Category hidden' : 'Category made public');
} catch (error) {
toast.error('Failed to reorder categories');
toast.error('Failed to update visibility');
console.error(error);
fetchCategories();
fetchCategories(); // Revert
}
};
// =============================================================================
// Drag and Drop
// =============================================================================
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(c => c.id === active.id);
const newIndex = categories.findIndex(c => c.id === over.id);
// Optimistic UI update
const newCategories = arrayMove(categories, oldIndex, newIndex);
setCategories(newCategories);
// Persist new order to database
try {
const updates = newCategories.map((category, index) =>
auditedSupabase.update('categories', category.id, { sort_order: index })
);
const results = await Promise.all(updates);
const hasError = results.some(r => r.error);
if (hasError) {
throw new Error('Failed to update some categories');
}
} catch (error) {
toast.error('Failed to save new order');
console.error(error);
fetchCategories(); // Revert on error
}
};
@@ -301,6 +486,19 @@ export function CategoriesPage() {
rows={3}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Public</Label>
<p className="text-sm text-muted-foreground">
Visible to users in the app
</p>
</div>
<Switch
checked={form.formData.is_public}
onCheckedChange={(checked) => form.setField('is_public', checked)}
/>
</div>
</div>
<DialogFooter>
@@ -337,73 +535,41 @@ export function CategoriesPage() {
</Button>
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories.map(c => c.id)}
strategy={rectSortingStrategy}
>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{categories.map((category, index) => (
<Card key={category.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(category, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(category, 'down')}
disabled={index === categories.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<div className="text-4xl mb-2">
<IconPreview value={category.icon} fallback="bookmark-outline" className="text-4xl" />
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => form.openEdit(category)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(category)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<CardTitle>{category.name}</CardTitle>
<CardDescription className="line-clamp-2">
{category.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</span>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" size="sm">
View Packs
</Button>
</Link>
</div>
</CardContent>
</Card>
{categories.map((category) => (
<SortableCategoryCard
key={category.id}
category={category}
onEdit={form.openEdit}
onDelete={handleDelete}
onToggleVisibility={handleToggleVisibility}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{categories.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* AI Dialog */}
@@ -0,0 +1,583 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
// =============================================================================
// Types
// =============================================================================
interface Category {
id: string;
name: string;
}
interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
created_at: string | null;
dare_count?: number;
}
interface DarePackFormData {
name: string;
description: string;
icon: string;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
category_id: string;
}
// =============================================================================
// Component
// =============================================================================
export function DarePacksPage() {
// Data state
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the hook
const form = useEntityForm<DarePackFormData, DarePack>(
{
name: '',
description: '',
icon: 'flame-outline',
is_premium: false,
is_public: false,
is_explicit: false,
category_id: '',
},
(pack) => ({
name: pack.name,
description: pack.description || '',
icon: pack.icon || 'flame-outline',
is_premium: pack.is_premium,
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
})
);
// =============================================================================
// Data Fetching
// =============================================================================
const fetchData = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch dare packs
const { data: packData, error, count } = await supabase
.from('dare_packs')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (error) throw error;
setTotalCount(count || 0);
// Fetch dare counts
const packIds = (packData || []).map(p => p.id);
const dareCounts: Record<string, number> = {};
if (packIds.length > 0) {
const { data: dares } = await supabase
.from('dares')
.select('pack_id')
.in('pack_id', packIds);
dares?.forEach(d => {
dareCounts[d.pack_id] = (dareCounts[d.pack_id] || 0) + 1;
});
}
// Fetch categories
const { data: categoryData } = await supabase
.from('categories')
.select('id, name')
.order('name');
setCategories(categoryData || []);
setDarePacks(
(packData || []).map(p => ({
...p,
dare_count: dareCounts[p.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load dare packs');
console.error(error);
} finally {
setLoading(false);
}
}, [page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// =============================================================================
// Handlers
// =============================================================================
const handleSave = async () => {
if (!form.formData.name.trim()) {
toast.error('Name is required');
return;
}
form.setSaving(true);
try {
if (form.editingItem) {
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
});
if (error) throw error;
toast.success('Dare pack updated');
} else {
const { error } = await auditedSupabase.insert('dare_packs', {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
sort_order: totalCount,
});
if (error) throw error;
toast.success('Dare pack created');
}
form.close();
fetchData();
} catch (error) {
toast.error('Failed to save dare pack');
console.error(error);
} finally {
form.setSaving(false);
}
};
const handleDelete = async (pack: DarePack) => {
if (!confirm(`Delete dare pack "${pack.name}"? This will delete all dares in this pack.`)) {
return;
}
try {
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
if (error) throw error;
toast.success('Dare pack deleted');
fetchData();
} catch (error) {
toast.error('Failed to delete dare pack');
console.error(error);
}
};
const handleMove = async (pack: DarePack, direction: 'up' | 'down') => {
const currentIndex = darePacks.findIndex(p => p.id === pack.id);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= darePacks.length) return;
const targetPack = darePacks[targetIndex];
try {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update dare pack order');
}
// Optimistic update
const newPacks = [...darePacks];
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
setDarePacks(newPacks);
} catch (error) {
toast.error('Failed to reorder dare packs');
console.error(error);
fetchData();
}
};
const getIntensityColor = (avg: number | null) => {
if (avg === null) return 'bg-muted';
if (avg <= 1.5) return 'bg-green-500';
if (avg <= 2.5) return 'bg-lime-500';
if (avg <= 3.5) return 'bg-yellow-500';
if (avg <= 4.5) return 'bg-orange-500';
return 'bg-red-500';
};
// =============================================================================
// Render
// =============================================================================
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-48" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Zap className="h-8 w-8 text-orange-500" />
Dare Packs
</h1>
<p className="text-muted-foreground">
Manage dare packs that users can send to their partners
</p>
</div>
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Dare Pack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{form.isEditing ? 'Edit Dare Pack' : 'Create Dare Pack'}
</DialogTitle>
<DialogDescription>
{form.isEditing
? 'Update the dare pack details below.'
: 'Add a new dare pack for couples to challenge each other.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Icon</Label>
<IconPicker
value={form.formData.icon}
onChange={(iconName) => form.setField('icon', iconName)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={form.formData.name}
onChange={(e) => form.setField('name', e.target.value)}
placeholder="e.g., Romantic Dares"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.formData.description}
onChange={(e) => form.setField('description', e.target.value)}
placeholder="Describe this dare pack..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Category (optional)</Label>
<Select
value={form.formData.category_id || '__none__'}
onValueChange={(value) => form.setField('category_id', value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No category</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Premium</Label>
<p className="text-xs text-muted-foreground">
Only available to premium users
</p>
</div>
<Switch
checked={form.formData.is_premium}
onCheckedChange={(checked) => form.setField('is_premium', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Published</Label>
<p className="text-xs text-muted-foreground">
Visible to users in the app
</p>
</div>
<Switch
checked={form.formData.is_public}
onCheckedChange={(checked) => form.setField('is_public', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Explicit</Label>
<p className="text-xs text-muted-foreground">
Contains adult/explicit content
</p>
</div>
<Switch
checked={form.formData.is_explicit}
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={form.close}>
Cancel
</Button>
<Button onClick={handleSave} disabled={form.saving}>
{form.saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Dare Packs Grid */}
{darePacks.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No dare packs yet</h3>
<p className="text-muted-foreground mb-4">
Create your first dare pack to get started
</p>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Dare Pack
</Button>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{darePacks.map((pack, index) => (
<Card key={pack.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(pack, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(pack, 'down')}
disabled={index === darePacks.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<div className="text-4xl mb-2">
<IconPreview value={pack.icon} fallback="flame-outline" className="text-4xl" />
</div>
</div>
<div className="flex items-center gap-1">
{pack.is_premium && (
<Badge variant="default" className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
)}
{pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-400">
<Eye className="h-3 w-3 mr-1" />
Published
</Badge>
) : (
<Badge variant="secondary">
<EyeOff className="h-3 w-3 mr-1" />
Draft
</Badge>
)}
{pack.is_explicit ? (
<Badge variant="outline" className="border-red-500 text-red-400">
<Flame className="h-3 w-3 mr-1" />
Explicit
</Badge>
) : (
<Badge variant="outline" className="border-pink-400 text-pink-300">
<Heart className="h-3 w-3 mr-1" />
Safe
</Badge>
)}
</div>
</div>
<div className="flex items-start justify-between">
<CardTitle className="flex-1">{pack.name}</CardTitle>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => form.openEdit(pack)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(pack)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<CardDescription className="line-clamp-2">
{pack.description || 'No description'}
</CardDescription>
{/* Intensity indicator */}
{pack.avg_intensity !== null && (
<div className="flex items-center gap-2 mt-2">
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
Avg: {pack.avg_intensity?.toFixed(1)}
</Badge>
<span className="text-xs text-muted-foreground">
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
</span>
</div>
)}
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
</span>
<Link to={`/dare-packs/${pack.id}/dares`}>
<Button variant="outline" size="sm">
Manage Dares
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)}
{darePacks.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+551
View File
@@ -0,0 +1,551 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { INTENSITY_LEVELS } from '@/lib/openai';
// =============================================================================
// Types
// =============================================================================
interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_explicit: boolean;
}
interface Dare {
id: string;
pack_id: string;
text: string;
intensity: number;
suggested_duration_hours: number | null;
created_at: string | null;
}
// Duration options for dares
const DURATION_OPTIONS = [
{ value: '', label: 'No suggestion' },
{ value: '1', label: '1 hour' },
{ value: '6', label: '6 hours' },
{ value: '12', label: '12 hours' },
{ value: '24', label: '24 hours' },
{ value: '72', label: '3 days' },
{ value: '168', label: '1 week' },
];
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
// Format duration for display
const formatDuration = (hours: number | null): string => {
if (hours === null) return '—';
if (hours < 24) return `${hours}h`;
if (hours < 168) return `${Math.round(hours / 24)}d`;
return `${Math.round(hours / 168)}w`;
};
// =============================================================================
// Component
// =============================================================================
export function DaresPage() {
const { packId } = useParams<{ packId: string }>();
const [pack, setPack] = useState<DarePack | null>(null);
const [dares, setDares] = useState<Dare[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingDare, setEditingDare] = useState<Dare | null>(null);
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Form state
const [formData, setFormData] = useState<{
text: string;
intensity: number;
suggested_duration_hours: string;
}>({
text: '',
intensity: 1,
suggested_duration_hours: '',
});
const fetchData = useCallback(async () => {
if (!packId) return;
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
.from('dare_packs')
.select('id, name, description, icon, is_explicit')
.eq('id', packId)
.single();
setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch dares
const { data: dareData, error, count } = await supabase
.from('dares')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
.order('created_at', { ascending: true })
.range(from, to);
if (error) throw error;
setTotalCount(count || 0);
setDares(dareData || []);
} catch (error) {
toast.error('Failed to load dares');
console.error(error);
} finally {
setLoading(false);
}
}, [packId, page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
}, [packId]);
useEffect(() => {
setSelectedIds(new Set());
}, [page, pageSize]);
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' });
setDialogOpen(true);
};
const openEditDialog = (dare: Dare) => {
setEditingDare(dare);
setFormData({
text: dare.text,
intensity: dare.intensity,
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.text.trim()) {
toast.error('Dare text is required');
return;
}
setSaving(true);
try {
const dareData = {
text: formData.text,
intensity: formData.intensity,
suggested_duration_hours: formData.suggested_duration_hours
? parseInt(formData.suggested_duration_hours, 10)
: null,
};
if (editingDare) {
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
if (error) throw error;
toast.success('Dare updated');
} else {
const { error } = await auditedSupabase.insert('dares', {
...dareData,
pack_id: packId,
});
if (error) throw error;
toast.success('Dare created');
}
setDialogOpen(false);
fetchData();
} catch (error) {
toast.error('Failed to save dare');
console.error(error);
} finally {
setSaving(false);
}
};
const handleDelete = async (dare: Dare) => {
if (!confirm('Delete this dare? This cannot be undone.')) {
return;
}
try {
const { error } = await auditedSupabase.delete('dares', dare.id);
if (error) throw error;
toast.success('Dare deleted');
fetchData();
} catch (error) {
toast.error('Failed to delete dare');
console.error(error);
}
};
// Selection handlers
const toggleSelection = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedIds(next);
};
const toggleSelectAll = () => {
if (selectedIds.size === dares.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(dares.map(d => d.id)));
}
};
const handleBulkDelete = async () => {
if (selectedIds.size === 0) return;
const count = selectedIds.size;
if (!confirm(`Delete ${count} dare${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
return;
}
setBulkDeleting(true);
try {
await Promise.all(
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
);
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
setSelectedIds(new Set());
fetchData();
} catch (error) {
toast.error('Failed to delete dares');
console.error(error);
} finally {
setBulkDeleting(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{pack && (
<>
<span className="text-3xl">
<IconPreview value={pack.icon} fallback="flame-outline" className="text-3xl" />
</span>
{pack.name}
</>
)}
</h1>
<p className="text-muted-foreground">
{totalCount} dare{totalCount !== 1 ? 's' : ''} in this pack
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Dare
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingDare ? 'Edit Dare' : 'Add Dare'}
</DialogTitle>
<DialogDescription>
{editingDare
? 'Update the dare details below.'
: 'Add a new dare to this pack.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="text">Dare Text</Label>
<Textarea
id="text"
value={formData.text}
onChange={(e) => setFormData(d => ({ ...d, text: e.target.value }))}
placeholder="e.g., Give your partner a 5-minute massage..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2 flex-wrap">
{INTENSITY_LEVELS.map((intensity) => (
<Button
key={intensity.level}
type="button"
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
>
{intensity.level} - {intensity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Suggested Duration</Label>
<Select
value={formData.suggested_duration_hours}
onValueChange={(value) => setFormData(d => ({ ...d, suggested_duration_hours: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
This is a suggestion shown to users when sending the dare. They can choose a different timeframe.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Bulk Action Bar */}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between bg-muted/50 border rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">
{selectedIds.size} dare{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
Clear selection
</Button>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleting}
>
{bulkDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete {selectedIds.size} Dare{selectedIds.size !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
)}
{/* Dares Table */}
{dares.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No dares yet</h3>
<p className="text-muted-foreground mb-4">
Add dares to this pack for couples to challenge each other
</p>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Dare
</Button>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedIds.size === dares.length && dares.length > 0}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dares.map((dare, index) => (
<TableRow
key={dare.id}
className={selectedIds.has(dare.id) ? 'bg-muted/50' : ''}
>
<TableCell>
<Checkbox
checked={selectedIds.has(dare.id)}
onCheckedChange={() => toggleSelection(dare.id)}
aria-label={`Select dare ${index + 1}`}
/>
</TableCell>
<TableCell className="font-medium text-muted-foreground">
{(page - 1) * pageSize + index + 1}
</TableCell>
<TableCell>
<span className="line-clamp-2">{dare.text}</span>
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(dare.intensity).color} text-white`}
title={getIntensity(dare.intensity).description}
>
{getIntensity(dare.intensity).label}
</Badge>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
<Clock className="h-3 w-3 mr-1" />
{formatDuration(dare.suggested_duration_hours)}
</Badge>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(dare)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(dare)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{dares.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+66 -12
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
@@ -21,7 +22,7 @@ import { IconPreview } from '@/components/ui/icon-picker';
interface Category {
id: string;
name: string;
icon: string | null;
icon?: string | null;
}
interface Topic {
@@ -53,6 +54,7 @@ export function PacksPage() {
// Data state
const [category, setCategory] = useState<Category | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [packs, setPacks] = useState<QuestionPack[]>([]);
const [loading, setLoading] = useState(true);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
@@ -60,6 +62,9 @@ export function PacksPage() {
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
const [allTopics, setAllTopics] = useState<Topic[]>([]);
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the hook
const form = useEntityForm<PackFormData, QuestionPack>(
@@ -70,6 +75,7 @@ export function PacksPage() {
is_premium: false,
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
},
(pack) => ({
name: pack.name,
@@ -78,6 +84,7 @@ export function PacksPage() {
is_premium: pack.is_premium,
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
})
);
@@ -98,30 +105,45 @@ export function PacksPage() {
setCategory(cat);
}
const { data: categoriesData } = await supabase
.from('categories')
.select('id, name')
.order('name');
setCategories(categoriesData || []);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch packs
let query = supabase
.from('question_packs')
.select('*')
.order('sort_order', { ascending: true });
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (categoryId) {
query = query.eq('category_id', categoryId);
}
const { data: packData, error } = await query;
const { data: packData, error, count } = await query;
if (error) throw error;
setTotalCount(count || 0);
// Fetch question counts
const packIds = (packData || []).map(p => p.id);
const questionCounts: Record<string, number> = {};
if (packIds.length > 0) {
const { data: questions } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
const questionCounts: Record<string, number> = {};
questions?.forEach(q => {
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
});
}
// Fetch all topics
const { data: topicsData } = await supabase
@@ -131,13 +153,17 @@ export function PacksPage() {
setAllTopics(topicsData || []);
// Fetch pack_topics relationships
const { data: packTopicsData } = await supabase
let packTopicsData: any[] = [];
if (packIds.length > 0) {
const { data } = await supabase
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds);
packTopicsData = data || [];
}
const packTopicsMap: Record<string, Topic[]> = {};
packTopicsData?.forEach((pt: any) => {
packTopicsData.forEach((pt: any) => {
if (!packTopicsMap[pt.pack_id]) {
packTopicsMap[pt.pack_id] = [];
}
@@ -159,12 +185,19 @@ export function PacksPage() {
} finally {
setLoading(false);
}
}, [categoryId]);
}, [categoryId, page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// =============================================================================
// Handlers
// =============================================================================
@@ -197,6 +230,7 @@ export function PacksPage() {
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
});
if (error) throw error;
packId = form.editingItem.id;
@@ -210,8 +244,8 @@ export function PacksPage() {
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: categoryId || null,
sort_order: packs.length,
category_id: form.formData.category_id || null,
sort_order: totalCount,
})
.select('id')
.single();
@@ -270,6 +304,7 @@ export function PacksPage() {
is_premium: false,
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
});
setAiDialogOpen(false);
setSelectedTopicIds(new Set());
@@ -288,14 +323,19 @@ export function PacksPage() {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
await Promise.all([
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update pack order');
}
// Optimistic update
const newPacks = [...packs];
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
setPacks(newPacks);
} catch (error) {
toast.error('Failed to reorder packs');
@@ -367,6 +407,7 @@ export function PacksPage() {
onSave={handleSave}
saving={form.saving}
isEditing={form.isEditing}
categories={categories}
allTopics={allTopics}
selectedTopicIds={selectedTopicIds}
onTopicsChange={setSelectedTopicIds}
@@ -535,6 +576,19 @@ export function PacksPage() {
))}
</div>
)}
{packs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,865 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
import { Crown, MessageCircle, PieChart, Sparkles, Tag, Eye, EyeOff } from 'lucide-react';
interface QuestionRow {
id: string;
pack_id: string;
intensity: number | null;
partner_text: string | null;
inverse_of?: string | null;
}
interface PackRow {
id: string;
name: string;
is_premium: boolean;
is_explicit: boolean;
is_public: boolean;
category_id: string | null;
category?: { name: string } | { name: string }[] | null;
}
interface TopicRow {
id: string;
name: string;
}
interface PackTopicRow {
pack_id: string;
topic_id: string;
topics: TopicRow | TopicRow[] | null;
}
interface BreakdownItem {
id: string;
name: string;
count: number;
}
interface PackBreakdownItem {
id: string;
name: string;
count: number;
isPremium: boolean;
category: string;
}
interface ChartDatum {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
type SegmentKey = 'all' | 'premium' | 'free';
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
const formatPercent = (count: number, total: number) => (total > 0 ? Math.round((count / total) * 100) : 0);
const getCategoryName = (category?: PackRow['category']) => {
if (!category) return 'Uncategorized';
if (Array.isArray(category)) {
return category[0]?.name || 'Uncategorized';
}
return category.name || 'Uncategorized';
};
const normalizeTopics = (topics?: PackTopicRow['topics']) => {
if (!topics) return [] as TopicRow[];
return Array.isArray(topics) ? topics : [topics];
};
type PublishFilter = 'all' | 'published' | 'unpublished';
export function QuestionAnalyticsPage() {
const [loading, setLoading] = useState(true);
const [publishFilter, setPublishFilter] = useState<PublishFilter>('published');
const [stats, setStats] = useState({
totalQuestions: 0,
premiumQuestions: 0,
freeQuestions: 0,
taggedQuestions: 0,
});
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
all: [],
premium: [],
free: [],
});
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [categoryBreakdown, setCategoryBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [packBreakdown, setPackBreakdown] = useState<Record<SegmentKey, PackBreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [intensitySegment, setIntensitySegment] = useState<SegmentKey>('all');
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
useEffect(() => {
const fetchAllQuestions = async () => {
const pageSize = 1000;
let page = 0;
const allQuestions: QuestionRow[] = [];
while (true) {
const from = page * pageSize;
const to = from + pageSize - 1;
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, intensity, partner_text, inverse_of')
.range(from, to);
if (error) throw error;
const batch = data || [];
allQuestions.push(...batch);
if (batch.length < pageSize) {
break;
}
page += 1;
}
return allQuestions;
};
const fetchAnalytics = async () => {
setLoading(true);
try {
// Build pack query with optional is_public filter
let packQuery = supabase
.from('question_packs')
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
if (publishFilter === 'published') {
packQuery = packQuery.eq('is_public', true);
} else if (publishFilter === 'unpublished') {
packQuery = packQuery.eq('is_public', false);
}
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
packQuery,
supabase
.from('topics')
.select('id, name')
.order('name'),
supabase
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)'),
fetchAllQuestions(),
]);
if (packsResult.error) throw packsResult.error;
if (topicsResult.error) throw topicsResult.error;
if (packTopicsResult.error) throw packTopicsResult.error;
const packs = (packsResult.data || []) as PackRow[];
const topics = (topicsResult.data || []) as TopicRow[];
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
const packById = new Map<string, PackRow>();
packs.forEach((pack) => {
packById.set(pack.id, pack);
});
const seedTopicCounts = () => new Map<string, BreakdownItem>(
topics.map((topic) => [topic.id, { id: topic.id, name: topic.name, count: 0 }])
);
const topicCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
all: seedTopicCounts(),
premium: seedTopicCounts(),
free: seedTopicCounts(),
};
const packTopicsMap = new Map<string, TopicRow[]>();
packTopics.forEach((relation) => {
const topicsForPack = packTopicsMap.get(relation.pack_id) || [];
const topicsList = normalizeTopics(relation.topics);
if (topicsList.length === 0) return;
topicsForPack.push(...topicsList);
packTopicsMap.set(relation.pack_id, topicsForPack);
});
const emptyIntensityCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
const intensityCounts: Record<SegmentKey, Record<number, number>> = {
all: { ...emptyIntensityCounts },
premium: { ...emptyIntensityCounts },
free: { ...emptyIntensityCounts },
};
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
const categoryCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
all: new Map(),
premium: new Map(),
free: new Map(),
};
const packCountsBySegment: Record<SegmentKey, Map<string, PackBreakdownItem>> = {
all: new Map(),
premium: new Map(),
free: new Map(),
};
let premiumCount = 0;
let freeCount = 0;
let taggedCount = 0;
const primaryQuestions = questions.filter((question) => question.inverse_of == null);
primaryQuestions.forEach((question) => {
const pack = packById.get(question.pack_id);
const isPremium = pack?.is_premium ?? false;
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
const categoryName = getCategoryName(pack?.category);
const categoryId = pack?.category_id || 'uncategorized';
if (isPremium) {
premiumCount += 1;
} else {
freeCount += 1;
}
const intensity = question.intensity ?? 1;
intensityCounts.all[intensity] = (intensityCounts.all[intensity] || 0) + 1;
intensityCounts[segmentKey][intensity] = (intensityCounts[segmentKey][intensity] || 0) + 1;
const updatePackCounts = (key: SegmentKey) => {
const map = packCountsBySegment[key];
const packEntry = map.get(question.pack_id) || {
id: question.pack_id,
name: pack?.name || 'Unknown Pack',
count: 0,
isPremium: isPremium,
category: categoryName,
};
packEntry.count += 1;
map.set(question.pack_id, packEntry);
};
updatePackCounts('all');
updatePackCounts(segmentKey);
const updateCategoryCounts = (key: SegmentKey) => {
const map = categoryCountsBySegment[key];
const categoryEntry = map.get(categoryId) || {
id: categoryId,
name: categoryName,
count: 0,
};
categoryEntry.count += 1;
map.set(categoryId, categoryEntry);
};
updateCategoryCounts('all');
updateCategoryCounts(segmentKey);
const topicsForPack = packTopicsMap.get(question.pack_id) || [];
if (topicsForPack.length > 0) {
taggedCount += 1;
topicsForPack.forEach((topic) => {
const updateCounts = (map: Map<string, BreakdownItem>) => {
const topicEntry = map.get(topic.id) || {
id: topic.id,
name: topic.name,
count: 0,
};
topicEntry.count += 1;
map.set(topic.id, topicEntry);
};
updateCounts(topicCountsBySegment.all);
updateCounts(topicCountsBySegment[segmentKey]);
});
} else {
untaggedCounts.all += 1;
untaggedCounts[segmentKey] += 1;
}
});
const totalQuestions = primaryQuestions.length;
const premiumData: ChartDatum[] = [
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
{ name: 'Free', value: freeCount, color: '#22c55e' },
];
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
name: label,
value: intensityCounts[key][index + 1] || 0,
color: intensityPalette[index],
}));
const buildTopicData = (key: SegmentKey) => {
const data = Array.from(topicCountsBySegment[key].values());
if (untaggedCounts[key] > 0 || data.length === 0) {
data.push({ id: 'untagged', name: 'Untagged', count: untaggedCounts[key] });
}
data.sort((a, b) => b.count - a.count);
return data;
};
const intensityData = {
all: buildIntensityData('all'),
premium: buildIntensityData('premium'),
free: buildIntensityData('free'),
};
const topicData = {
all: buildTopicData('all'),
premium: buildTopicData('premium'),
free: buildTopicData('free'),
};
const buildCategoryData = (key: SegmentKey) =>
Array.from(categoryCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
const buildPackData = (key: SegmentKey) =>
Array.from(packCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
const categoryData = {
all: buildCategoryData('all'),
premium: buildCategoryData('premium'),
free: buildCategoryData('free'),
};
const packData = {
all: buildPackData('all'),
premium: buildPackData('premium'),
free: buildPackData('free'),
};
setStats({
totalQuestions,
premiumQuestions: premiumCount,
freeQuestions: freeCount,
taggedQuestions: taggedCount,
});
setPremiumBreakdown(premiumData);
setIntensityBreakdown(intensityData);
setTopicBreakdown(topicData);
setCategoryBreakdown(categoryData);
setPackBreakdown(packData);
} catch (error) {
console.error('Failed to fetch question analytics:', error);
} finally {
setLoading(false);
}
};
fetchAnalytics();
}, [publishFilter]);
const premiumPercent = formatPercent(stats.premiumQuestions, stats.totalQuestions);
const freePercent = formatPercent(stats.freeQuestions, stats.totalQuestions);
const taggedPercent = formatPercent(stats.taggedQuestions, stats.totalQuestions);
const segmentOptions: { key: SegmentKey; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'premium', label: 'Premium' },
{ key: 'free', label: 'Free' },
];
const segmentLabelByKey: Record<SegmentKey, string> = {
all: 'All',
premium: 'Premium',
free: 'Free',
};
const totalBySegment: Record<SegmentKey, number> = {
all: stats.totalQuestions,
premium: stats.premiumQuestions,
free: stats.freeQuestions,
};
const segmentShareByKey: Record<SegmentKey, string> = {
all: '100',
premium: stats.totalQuestions ? ((stats.premiumQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
};
const intensityData = intensityBreakdown[intensitySegment];
const tagData = topicBreakdown[tagSegment];
const categoryData = categoryBreakdown[categorySegment];
const packData = packBreakdown[packSegment];
const publishFilterOptions: { key: PublishFilter; label: string; icon: typeof Eye }[] = [
{ key: 'all', label: 'All Packs', icon: Eye },
{ key: 'published', label: 'Published Only', icon: Eye },
{ key: 'unpublished', label: 'Unpublished Only', icon: EyeOff },
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<PieChart className="h-8 w-8 text-primary" />
Question Analytics
</h1>
<p className="text-muted-foreground">
Deep dive into premium coverage, tags, categories, and intensity distribution
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Scope</p>
<div className="flex items-center gap-2">
{publishFilterOptions.map((option) => {
const Icon = option.icon;
return (
<Button
key={option.key}
variant={publishFilter === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setPublishFilter(option.key)}
className="gap-2"
>
<Icon className="h-4 w-4" />
{option.label}
</Button>
);
})}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Questions</CardTitle>
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">
{stats.totalQuestions.toLocaleString()}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Premium Questions</CardTitle>
<Crown className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.premiumQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{premiumPercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Free Questions</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.freeQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{freePercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tagged Questions</CardTitle>
<Tag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.taggedQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{taggedPercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Premium vs Free</CardTitle>
<CardDescription>
Question count split by pack premium status
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : stats.totalQuestions === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No question data available yet
</div>
) : (
<div className="h-[300px] flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie
data={premiumBreakdown}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ name, percent }) =>
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
}
>
{premiumBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Intensity Distribution</CardTitle>
<CardDescription>
How questions are spread across intensity levels
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={intensitySegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setIntensitySegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : totalBySegment[intensitySegment] === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No questions in this segment yet
</div>
) : (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={intensityData}>
<XAxis
dataKey="name"
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
{intensityData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Tag Breakdown</CardTitle>
<CardDescription>
Tags are derived from pack topics; questions in multi-tag packs count toward each tag
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={tagSegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setTagSegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[tagSegment]}: {totalBySegment[tagSegment].toLocaleString()} questions ({segmentShareByKey[tagSegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-14 w-full" />
))}
</div>
) : totalBySegment[tagSegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : tagData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No tag data available yet
</div>
) : (
<div className="space-y-4">
{tagData.map((item) => {
const percentage = formatPercent(item.count, totalBySegment[tagSegment]);
return (
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border px-4 py-3"
>
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="font-medium">{item.name}</p>
<span className="text-sm text-muted-foreground">
{item.count.toLocaleString()}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<div className="w-12 text-right text-sm text-muted-foreground">
{percentage}%
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Category Breakdown</CardTitle>
<CardDescription>
Distribution of questions across pack categories
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={categorySegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setCategorySegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[categorySegment]}: {totalBySegment[categorySegment].toLocaleString()} questions ({segmentShareByKey[categorySegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : totalBySegment[categorySegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : categoryData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No category data available yet
</div>
) : (
<div className="space-y-3">
{categoryData.map((item) => {
const percentage = formatPercent(item.count, totalBySegment[categorySegment]);
return (
<div
key={item.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<p className="font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{percentage}% of questions</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{item.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Top Packs</CardTitle>
<CardDescription>
Packs with the most questions right now
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={packSegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setPackSegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[packSegment]}: {totalBySegment[packSegment].toLocaleString()} questions ({segmentShareByKey[packSegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : totalBySegment[packSegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : packData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No pack data available yet
</div>
) : (
<div className="space-y-3">
{packData.slice(0, 8).map((pack) => (
<div
key={pack.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div className="min-w-0">
<p className="font-medium truncate">{pack.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pack.category}</span>
{pack.isPremium && (
<Badge variant="secondary" className="text-[10px]">
Premium
</Badge>
)}
</div>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{pack.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+362 -30
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
@@ -6,9 +6,11 @@ import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
@@ -27,7 +29,14 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, Link2, Eye } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
@@ -50,6 +59,8 @@ interface Question {
intensity: number;
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
required_props?: string[] | null;
inverse_of?: string | null;
created_at: string | null;
}
@@ -66,10 +77,18 @@ export function QuestionsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewQuestions, setReviewQuestions] = useState<Question[]>([]);
const [reviewLoading, setReviewLoading] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [peekOpen, setPeekOpen] = useState(false);
const [peekPrimary, setPeekPrimary] = useState<Question | null>(null);
const [peekInverse, setPeekInverse] = useState<Question | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Form state
const [formData, setFormData] = useState<{
@@ -78,77 +97,147 @@ export function QuestionsPage() {
intensity: number;
allowed_couple_genders: string[];
target_user_genders: string[];
required_props: string;
inverse_of: string;
}>({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
inverse_of: '',
});
// All questions in pack for inverse_of dropdown (loaded once)
const [allPackQuestions, setAllPackQuestions] = useState<Question[]>([]);
const fetchData = useCallback(async () => {
if (!packId) return;
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const packPromise = supabase
.from('question_packs')
.select('id, name, description, icon, is_explicit')
.eq('id', packId)
.single();
setPack(packData);
// Fetch questions
const { data: questionData, error } = await supabase
const questionsPromise = supabase
.from('questions')
.select('*')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
.order('created_at', { ascending: true })
.range(from, to);
const allPackPromise = supabase
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
.is('deleted_at', null)
.order('created_at', { ascending: true });
const [
{ data: packData, error: packError },
{ data: questionData, error, count },
{ data: allPackData, error: allPackError },
] = await Promise.all([packPromise, questionsPromise, allPackPromise]);
if (packError) throw packError;
if (error) throw error;
setTotalCount(count || 0);
setQuestions(questionData || []);
setPack(packData);
if (allPackError) {
toast.error('Failed to load inverse peek data');
console.error(allPackError);
} else {
setAllPackQuestions((allPackData as Question[]) || []);
}
} catch (error) {
toast.error('Failed to load questions');
console.error(error);
} finally {
setLoading(false);
}
}, [packId]);
}, [packId, page, pageSize]);
// Real-time subscription for questions in this pack
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
table: 'questions',
filter: packId ? `pack_id=eq.${packId}` : undefined,
enabled: !!packId,
onInsert: useCallback((newQuestion: Question) => {
setQuestions(prev => [...prev, newQuestion]);
}, []),
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => {
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q));
}, []),
onInsert: useCallback(() => {
fetchData();
}, [fetchData]),
onUpdate: useCallback(() => {
fetchData();
}, [fetchData]),
onDelete: useCallback((deleted: Question) => {
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(deleted.id);
return next;
});
}, []),
fetchData();
}, [fetchData]),
});
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreateDialog = () => {
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
setReviewQuestions([]);
setReviewOpen(false);
}, [packId]);
useEffect(() => {
setSelectedIds(new Set());
}, [page, pageSize]);
// Fetch all questions in pack for inverse_of dropdown
const fetchAllPackQuestions = useCallback(async () => {
if (!packId) return;
const { data } = await supabase
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
.is('deleted_at', null)
.order('created_at', { ascending: true });
setAllPackQuestions((data as Question[]) || []);
}, [packId]);
const primaryQuestionById = useMemo(() => {
return new Map(allPackQuestions.map(question => [question.id, question]));
}, [allPackQuestions]);
const openCreateDialog = async () => {
setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
setFormData({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
inverse_of: '',
});
await fetchAllPackQuestions();
setDialogOpen(true);
};
const openEditDialog = (question: Question) => {
const openEditDialog = async (question: Question) => {
setEditingQuestion(question);
setFormData({
text: question.text,
@@ -156,7 +245,10 @@ export function QuestionsPage() {
intensity: question.intensity,
allowed_couple_genders: question.allowed_couple_genders || [],
target_user_genders: question.target_user_genders || [],
required_props: question.required_props?.join(', ') ?? '',
inverse_of: question.inverse_of || '',
});
await fetchAllPackQuestions();
setDialogOpen(true);
};
@@ -166,6 +258,12 @@ export function QuestionsPage() {
return;
}
const requiredProps = formData.required_props
.split(/[,;\n]/)
.map(value => value.trim())
.filter(Boolean);
const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null;
setSaving(true);
try {
if (editingQuestion) {
@@ -175,6 +273,8 @@ export function QuestionsPage() {
intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
required_props: requiredPropsValue,
inverse_of: formData.inverse_of || null,
});
if (error) throw error;
@@ -187,6 +287,8 @@ export function QuestionsPage() {
intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
required_props: requiredPropsValue,
inverse_of: formData.inverse_of || null,
});
if (error) throw error;
@@ -220,21 +322,104 @@ export function QuestionsPage() {
}
};
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number }>) => {
// Bulk insert AI-generated questions
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
// Bulk insert AI-generated questions with inverse_of linking
const insertQuestions = async () => {
try {
const { error } = await auditedSupabase.insert(
// Group questions by inverse_pair_id
const pairMap = new Map<string, typeof generatedQuestions>();
const standaloneQuestions: typeof generatedQuestions = [];
for (const q of generatedQuestions) {
if (q.inverse_pair_id) {
const existing = pairMap.get(q.inverse_pair_id) || [];
existing.push(q);
pairMap.set(q.inverse_pair_id, existing);
} else {
standaloneQuestions.push(q);
}
}
// Insert standalone questions first (no inverse_of)
if (standaloneQuestions.length > 0) {
const { error: standaloneError } = await auditedSupabase.insert(
'questions',
generatedQuestions.map(q => ({
standaloneQuestions.map(q => ({
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
inverse_of: null,
}))
);
if (standaloneError) throw standaloneError;
}
// Insert paired questions: primary first, then inverse with inverse_of link
for (const [, pair] of pairMap) {
if (pair.length >= 2) {
// First question is primary (inverse_of = NULL)
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
inverse_of: null,
}]
);
if (primaryError) throw primaryError;
// Second question is inverse (inverse_of = primary's id)
const primaryId = primaryData?.[0]?.id;
if (primaryId) {
const inverse = pair[1];
const { error: inverseError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
inverse_of: primaryId,
}]
);
if (inverseError) throw inverseError;
}
// Handle any additional questions in the pair (shouldn't happen, but just in case)
for (let i = 2; i < pair.length; i++) {
const extra = pair[i];
const { error: extraError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
inverse_of: null,
}]
);
if (extraError) throw extraError;
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair - treat as standalone
const { error } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
inverse_of: null,
}]
);
if (error) throw error;
}
}
toast.success(`${generatedQuestions.length} questions added`);
fetchData();
} catch (error) {
@@ -247,6 +432,28 @@ export function QuestionsPage() {
setAiDialogOpen(false);
};
const handleReviewOpen = async () => {
if (!packId) return;
setReviewLoading(true);
try {
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, created_at')
.eq('pack_id', packId)
.order('created_at', { ascending: true });
if (error) throw error;
setReviewQuestions(data || []);
setReviewOpen(true);
} catch (error) {
toast.error('Failed to load questions for review');
console.error(error);
} finally {
setReviewLoading(false);
}
};
// Selection handlers
const toggleSelection = (id: string) => {
const next = new Set(selectedIds);
@@ -290,6 +497,13 @@ export function QuestionsPage() {
}
};
const handlePeekPrimary = (question: Question) => {
if (!question.inverse_of) return;
setPeekPrimary(primaryQuestionById.get(question.inverse_of) ?? null);
setPeekInverse(question);
setPeekOpen(true);
};
if (loading) {
return (
<div className="space-y-6">
@@ -319,12 +533,16 @@ export function QuestionsPage() {
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
{totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setReviewOpen(true)}>
<Button variant="outline" onClick={handleReviewOpen} disabled={reviewLoading}>
{reviewLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Review Questions
</Button>
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
@@ -417,6 +635,52 @@ export function QuestionsPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="required_props">Required Props (optional)</Label>
<Input
id="required_props"
value={formData.required_props}
onChange={(e) => setFormData(d => ({ ...d, required_props: e.target.value }))}
placeholder="e.g., vibrator, restraints"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of props/accessories needed for this question.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="inverse_of">Inverse Of (optional)</Label>
<Badge variant="outline" className="text-xs">
<Link2 className="h-3 w-3 mr-1" />
Paired
</Badge>
</div>
<Select
value={formData.inverse_of || 'none'}
onValueChange={(value) => setFormData(d => ({ ...d, inverse_of: value === 'none' ? '' : value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select primary question..." />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="none">
<span className="text-muted-foreground">None (this is a standalone question)</span>
</SelectItem>
{allPackQuestions
.filter(q => q.id !== editingQuestion?.id && !q.inverse_of)
.map(q => (
<SelectItem key={q.id} value={q.id}>
<span className="line-clamp-1">{q.text}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Link this question to its inverse (e.g., "give X" → "receive X"). The selected question is the primary; this one becomes its inverse.
Only standalone questions can be selected as primaries.
</p>
</div>
<div className="space-y-2">
<Label>Target Couples (optional)</Label>
@@ -555,11 +819,38 @@ export function QuestionsPage() {
<ReviewQuestionsDialog
open={reviewOpen}
onOpenChange={setReviewOpen}
questions={questions}
questions={reviewQuestions}
isExplicit={pack?.is_explicit ?? false}
onUpdated={fetchData}
/>
<Dialog open={peekOpen} onOpenChange={setPeekOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Primary Question</DialogTitle>
<DialogDescription>
Quick peek for the inverse question link.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium">Primary</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{peekPrimary?.text || 'Primary question not found.'}
</p>
</div>
{peekInverse && (
<div className="space-y-1">
<p className="text-sm font-medium">Inverse</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{peekInverse.text}
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Questions Table */}
{
questions.length === 0 ? (
@@ -594,6 +885,7 @@ export function QuestionsPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead>
<TableHead className="w-20">Inverse</TableHead>
<TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead>
@@ -615,11 +907,38 @@ export function QuestionsPage() {
/>
</TableCell>
<TableCell className="font-medium text-muted-foreground">
{index + 1}
{(page - 1) * pageSize + index + 1}
</TableCell>
<TableCell>
<span className="line-clamp-2">{question.text}</span>
</TableCell>
<TableCell>
{question.inverse_of ? (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs border-blue-500 text-blue-500" title="This is an inverse of another question">
<Link2 className="h-3 w-3 mr-1" />
Inverse
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handlePeekPrimary(question)}
aria-label="Peek primary question"
title="Peek primary question"
>
<Eye className="h-3 w-3" />
</Button>
</div>
) : questions.some(q => q.inverse_of === question.id) ? (
<Badge variant="outline" className="text-xs border-green-500 text-green-500" title="This question has an inverse">
<Link2 className="h-3 w-3 mr-1" />
Primary
</Badge>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
<TableCell>
{question.partner_text ? (
<Badge variant="secondary" className="text-xs">
@@ -689,6 +1008,19 @@ export function QuestionsPage() {
</div>
)
}
{questions.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div >
);
}
+459
View File
@@ -0,0 +1,459 @@
import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { CheckCircle2, Loader2, Sparkles, Tags } from 'lucide-react';
import { extractTopicsFromPack } from '@/lib/openai';
interface TopicRow {
id: string;
name: string;
}
interface PackTopicRow {
pack_id: string;
topic_id: string;
}
interface PackRow {
id: string;
name: string;
description: string | null;
is_public: boolean;
category?: { name: string } | { name: string }[] | null;
}
interface ActiveTag {
id: string;
name: string;
packCount: number;
}
interface UntaggedPack {
id: string;
name: string;
description: string | null;
is_public: boolean;
categoryName: string;
questionCount: number;
}
const getCategoryName = (category?: PackRow['category']) => {
if (!category) return 'Uncategorized';
if (Array.isArray(category)) {
return category[0]?.name || 'Uncategorized';
}
return category.name || 'Uncategorized';
};
export function TagsPage() {
const [activeTags, setActiveTags] = useState<ActiveTag[]>([]);
const [untaggedPacks, setUntaggedPacks] = useState<UntaggedPack[]>([]);
const [loading, setLoading] = useState(true);
const [taggingAll, setTaggingAll] = useState(false);
const [taggingProgress, setTaggingProgress] = useState<{
current: number;
total: number;
packName: string;
} | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
supabase.from('topics').select('id, name').order('name'),
supabase.from('pack_topics').select('pack_id, topic_id'),
supabase
.from('question_packs')
.select('id, name, description, is_public, category:categories(name)')
.order('name'),
]);
if (topicsResult.error) throw topicsResult.error;
if (packTopicsResult.error) throw packTopicsResult.error;
if (packsResult.error) throw packsResult.error;
const topics = (topicsResult.data || []) as TopicRow[];
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
const packs = (packsResult.data || []) as PackRow[];
const topicCounts = new Map<string, number>();
const packIdsWithTopics = new Set<string>();
packTopics.forEach((relation) => {
if (!relation.topic_id || !relation.pack_id) return;
packIdsWithTopics.add(relation.pack_id);
topicCounts.set(relation.topic_id, (topicCounts.get(relation.topic_id) || 0) + 1);
});
const activeTagList = topics
.filter((topic) => topicCounts.has(topic.id))
.map((topic) => ({
id: topic.id,
name: topic.name,
packCount: topicCounts.get(topic.id) || 0,
}))
.sort((a, b) => {
if (b.packCount !== a.packCount) return b.packCount - a.packCount;
return a.name.localeCompare(b.name);
});
const untagged = packs.filter((pack) => !packIdsWithTopics.has(pack.id));
const untaggedPackIds = untagged.map((pack) => pack.id);
const questionCounts: Record<string, number> = {};
if (untaggedPackIds.length > 0) {
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', untaggedPackIds);
if (questionsError) throw questionsError;
questions?.forEach((question) => {
if (!question.pack_id) return;
questionCounts[question.pack_id] = (questionCounts[question.pack_id] || 0) + 1;
});
}
setActiveTags(activeTagList);
setUntaggedPacks(
untagged.map((pack) => ({
id: pack.id,
name: pack.name,
description: pack.description,
is_public: pack.is_public,
categoryName: getCategoryName(pack.category),
questionCount: questionCounts[pack.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load tags');
console.error(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const eligiblePackCount = untaggedPacks.filter((pack) => pack.questionCount > 0).length;
const handleTagAll = async () => {
if (taggingAll || eligiblePackCount === 0) return;
const eligiblePacks = untaggedPacks.filter((pack) => pack.questionCount > 0);
const skippedNoQuestions = untaggedPacks.length - eligiblePacks.length;
if (eligiblePacks.length === 0) {
toast('No untagged packs with questions to tag');
return;
}
const shouldProceed = confirm(
`Tag ${eligiblePacks.length} untagged pack${eligiblePacks.length !== 1 ? 's' : ''} with AI?` +
(skippedNoQuestions > 0
? ` ${skippedNoQuestions} pack${skippedNoQuestions !== 1 ? 's' : ''} without questions will be skipped.`
: '')
);
if (!shouldProceed) return;
setTaggingAll(true);
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
try {
const { data: topicsData, error: topicsError } = await supabase
.from('topics')
.select('id, name')
.order('name');
if (topicsError) throw topicsError;
const existingTopics: TopicRow[] = [...(topicsData || [])];
let taggedCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (let index = 0; index < eligiblePacks.length; index += 1) {
const pack = eligiblePacks[index];
setTaggingProgress({
current: index + 1,
total: eligiblePacks.length,
packName: pack.name,
});
try {
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('text')
.eq('pack_id', pack.id);
if (questionsError) throw questionsError;
if (!questions || questions.length === 0) {
skippedCount += 1;
continue;
}
const result = await extractTopicsFromPack(
pack.name,
pack.description,
questions.map((question) => question.text),
existingTopics
);
if (!result.topics || result.topics.length === 0) {
skippedCount += 1;
continue;
}
const existingByName = new Map(
existingTopics.map((topic) => [topic.name.toLowerCase(), topic.id])
);
const newTopics: { name: string }[] = [];
const topicIds: string[] = [];
result.topics.forEach((topic) => {
const normalizedName = topic.name.trim();
if (!normalizedName) return;
if (!topic.isNew && topic.existingTopicId) {
topicIds.push(topic.existingTopicId);
return;
}
const existingId = existingByName.get(normalizedName.toLowerCase());
if (existingId) {
topicIds.push(existingId);
return;
}
newTopics.push({ name: normalizedName });
});
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase
.from('topics')
.insert(newTopics)
.select('id, name');
if (createError) throw createError;
createdTopics?.forEach((topic) => {
topicIds.push(topic.id);
existingTopics.push({ id: topic.id, name: topic.name });
});
}
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
if (topicIds.length === 0) {
skippedCount += 1;
continue;
}
const { error: linkError } = await supabase
.from('pack_topics')
.insert(topicIds.map((topicId) => ({
pack_id: pack.id,
topic_id: topicId,
})));
if (linkError) throw linkError;
taggedCount += 1;
} catch (error) {
failedCount += 1;
console.error(`Failed to tag pack ${pack.name}:`, error);
}
}
if (failedCount > 0) {
toast.error(`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}. ${failedCount} failed.`);
} else {
toast.success(
`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}.` +
(skippedCount > 0 ? ` ${skippedCount} skipped.` : '')
);
}
} catch (error) {
toast.error('Failed to tag all packs');
console.error(error);
} finally {
setTaggingAll(false);
setTaggingProgress(null);
fetchData();
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Tags className="h-8 w-8 text-primary" />
Tags
</h1>
<p className="text-muted-foreground">
Active tags and packs that still need topic coverage
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<Button
onClick={handleTagAll}
disabled={taggingAll || eligiblePackCount === 0}
>
{taggingAll ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{taggingAll && taggingProgress
? `Tagging ${taggingProgress.current}/${taggingProgress.total}`
: 'Tag All Untagged'}
</Button>
{taggingProgress?.packName && (
<p className="text-xs text-muted-foreground">
Working on {taggingProgress.packName}
</p>
)}
{eligiblePackCount === 0 && untaggedPacks.length > 0 && (
<p className="text-xs text-muted-foreground">
Add questions to enable auto-tagging.
</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Tags</CardTitle>
<Tags className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeTags.length.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">Tags assigned to at least one pack</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Untagged Packs</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{untaggedPacks.length.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">Packs missing any tag coverage</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Active Tags</CardTitle>
<CardDescription>Tag usage across question packs</CardDescription>
</CardHeader>
<CardContent>
{activeTags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mb-2" />
No active tags yet
</div>
) : (
<div className="space-y-3">
{activeTags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<p className="font-medium">{tag.name}</p>
<Badge variant="secondary">
{tag.packCount} pack{tag.packCount !== 1 ? 's' : ''}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Untagged Packs</CardTitle>
<CardDescription>These packs still need topics</CardDescription>
</div>
<Badge variant="outline" className="text-xs">
{untaggedPacks.length} total
</Badge>
</CardHeader>
<CardContent>
{untaggedPacks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mb-2" />
All packs are tagged
</div>
) : (
<div className="space-y-3">
{untaggedPacks.map((pack) => (
<div
key={pack.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{pack.name}</p>
{pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-400">
Published
</Badge>
) : (
<Badge variant="secondary">Draft</Badge>
)}
{pack.questionCount === 0 && (
<Badge variant="outline" className="border-yellow-500 text-yellow-400">
No questions
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{pack.categoryName}</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{pack.questionCount}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+68 -28
View File
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
interface Profile {
id: string;
@@ -331,25 +332,11 @@ export function MatchChatPage() {
const [match, setMatch] = useState<Match | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
// Real-time subscription for messages in this chat
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId,
onInsert: useCallback((newMessage: Message) => {
setMessages(prev => [...prev, newMessage]);
}, []),
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => {
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m));
}, []),
onDelete: useCallback((deleted: Message) => {
setMessages(prev => prev.filter(m => m.id !== deleted.id));
}, []),
});
useEffect(() => {
const fetchData = async () => {
const fetchData = useCallback(async () => {
if (!userId || !matchId) return;
setLoading(true);
@@ -373,6 +360,8 @@ export function MatchChatPage() {
.single();
setPartner(partnerData);
} else {
setPartner(null);
}
// Fetch match with question
@@ -387,23 +376,59 @@ export function MatchChatPage() {
setMatch(matchData as unknown as Match);
// Fetch messages
const { data: messageData } = await supabase
.from('messages')
.select('*')
.eq('match_id', matchId)
.order('created_at', { ascending: true });
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
setMessages(messageData || []);
// Fetch messages
const { data: messageData, error: messagesError, count } = await supabase
.from('messages')
.select('*', { count: 'exact' })
.eq('match_id', matchId)
.order('created_at', { ascending: false })
.range(from, to);
if (messagesError) throw messagesError;
setTotalCount(count || 0);
const orderedMessages = (messageData || []).slice().reverse();
setMessages(orderedMessages);
} catch (error) {
console.error('Failed to load chat data:', error);
} finally {
setLoading(false);
}
};
}, [userId, matchId, page, pageSize]);
// Real-time subscription for messages in this chat
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId,
onInsert: useCallback(() => {
fetchData();
}, [userId, matchId]);
}, [fetchData]),
onUpdate: useCallback(() => {
fetchData();
}, [fetchData]),
onDelete: useCallback(() => {
fetchData();
}, [fetchData]),
});
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
}, [matchId]);
const getProfile = (senderId: string): Profile | null => {
if (senderId === user?.id) return user;
@@ -497,7 +522,7 @@ export function MatchChatPage() {
<Card className="min-h-[400px]">
<CardHeader className="py-4 border-b">
<CardTitle className="text-sm font-medium">
Messages ({messages.length})
Messages ({totalCount})
</CardTitle>
</CardHeader>
<CardContent className="p-4">
@@ -580,6 +605,21 @@ export function MatchChatPage() {
})}
</div>
)}
{totalCount > 0 && (
<div className="mt-4">
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
</div>
)}
</CardContent>
</Card>
</div>
+101 -14
View File
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -237,6 +238,15 @@ export function UserDetailPage() {
const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]);
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(10);
const [responsesTotal, setResponsesTotal] = useState(0);
const [matchesPage, setMatchesPage] = useState(1);
const [matchesPageSize, setMatchesPageSize] = useState(10);
const [matchesTotal, setMatchesTotal] = useState(0);
const [mediaPage, setMediaPage] = useState(1);
const [mediaPageSize, setMediaPageSize] = useState(12);
const [mediaTotal, setMediaTotal] = useState(0);
const [loading, setLoading] = useState(true);
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
@@ -308,44 +318,121 @@ export function UserDetailPage() {
setPartner(null);
}
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
const responsesFrom = (responsesPage - 1) * responsesPageSize;
const responsesTo = responsesFrom + responsesPageSize - 1;
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
.from('responses')
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range(responsesFrom, responsesTo);
if (responsesError) throw responsesError;
setResponses((responseData || []) as unknown as Response[]);
setResponsesTotal(responsesCount || 0);
if (profileData?.couple_id) {
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
const matchesFrom = (matchesPage - 1) * matchesPageSize;
const matchesTo = matchesFrom + matchesPageSize - 1;
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
.from('matches')
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
.eq('couple_id', profileData.couple_id)
.order('created_at', { ascending: false })
.range(matchesFrom, matchesTo);
if (matchesError) throw matchesError;
setMatchesTotal(matchesCount || 0);
const matchIds = (matchData || []).map(m => m.id);
if (matchIds.length > 0) {
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
} else {
setMatches([]);
}
const { data: allMatchIdsData } = await supabase
.from('matches')
.select('id')
.eq('couple_id', profileData.couple_id);
const allMatchIds = (allMatchIdsData || []).map(m => m.id);
if (allMatchIds.length > 0) {
const mediaFrom = (mediaPage - 1) * mediaPageSize;
const mediaTo = mediaFrom + mediaPageSize - 1;
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
.from('messages')
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
.in('match_id', allMatchIds)
.not('media_path', 'is', null)
.order('created_at', { ascending: false })
.range(mediaFrom, mediaTo);
if (mediaError) {
console.error("Error fetching media messages:", mediaError);
setMediaMessages([]);
setMediaTotal(0);
} else {
setMediaMessages(mediaMessagesData || []);
setMediaTotal(mediaCount || 0);
}
} else {
setMediaMessages([]);
setMediaTotal(0);
}
} else {
setMatches([]);
setMatchesTotal(0);
setMediaMessages([]);
}
} else {
setMatches([]);
setMediaMessages([]);
setMediaTotal(0);
}
} catch (error) {
console.error('Failed to load user data:', error);
} finally {
setLoading(false);
}
}, [userId]);
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => {
setResponsesPage(1);
setMatchesPage(1);
setMediaPage(1);
}, [userId]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
if (responsesPage > totalPages) {
setResponsesPage(totalPages);
}
}, [responsesPage, responsesPageSize, responsesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
if (matchesPage > totalPages) {
setMatchesPage(totalPages);
}
}, [matchesPage, matchesPageSize, matchesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(mediaTotal / mediaPageSize));
if (mediaPage > totalPages) {
setMediaPage(totalPages);
}
}, [mediaPage, mediaPageSize, mediaTotal]);
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
@@ -378,19 +465,19 @@ export function UserDetailPage() {
{/* Tabs */}
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList>
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
</TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{message.media_type === 'video' ? (
<AdminDecryptedVideo messageId={message.id} />
) : (
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
)}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
</Tabs>
</div>
);
+36 -4
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -46,16 +47,20 @@ export function UsersPage() {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const fetchProfiles = useCallback(async () => {
setLoading(true);
try {
// Fetch profiles with auth info (includes last_sign_in_at)
const { data: profilesData, error } = await supabase
const { data: profilesDataRaw, error } = await supabase
.rpc('get_profiles_with_auth_info');
if (error) throw error;
const profilesData = (profilesDataRaw || []) as Profile[];
// Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase
.rpc('get_user_storage_usage');
@@ -70,7 +75,7 @@ export function UsersPage() {
// Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
(profilesData || []).forEach(profile => {
profilesData.forEach(profile => {
if (profile.couple_id) {
if (!couplePartners[profile.couple_id]) {
couplePartners[profile.couple_id] = [];
@@ -83,7 +88,7 @@ export function UsersPage() {
});
// Merge storage data and partner premium status with profiles
const profilesWithExtras = (profilesData || []).map(profile => {
const profilesWithExtras = profilesData.map(profile => {
// Check if partner has premium
let partnerIsPremium = false;
if (profile.couple_id && couplePartners[profile.couple_id]) {
@@ -134,6 +139,7 @@ export function UsersPage() {
}, [fetchProfiles]);
useEffect(() => {
setPage(1);
if (!search.trim()) {
setFilteredProfiles(profiles);
return;
@@ -150,6 +156,19 @@ export function UsersPage() {
);
}, [search, profiles]);
const totalCount = filteredProfiles.length;
const paginatedProfiles = filteredProfiles.slice(
(page - 1) * pageSize,
page * pageSize
);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
if (loading) {
return (
<div className="space-y-6">
@@ -209,7 +228,7 @@ export function UsersPage() {
</TableCell>
</TableRow>
) : (
filteredProfiles.map((profile) => (
paginatedProfiles.map((profile) => (
<TableRow key={profile.id}>
<TableCell>
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
@@ -294,6 +313,19 @@ export function UsersPage() {
</TableBody>
</Table>
</div>
{filteredProfiles.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+6 -1
View File
@@ -12,6 +12,7 @@ export interface Category {
icon: string | null;
sort_order: number | null;
created_at: string | null;
is_public: boolean;
pack_count?: number;
}
@@ -19,6 +20,7 @@ export interface CategoryFormData {
name: string;
description: string;
icon: string;
is_public: boolean;
}
// =============================================================================
@@ -85,10 +87,12 @@ export async function createCategory(
description: data.description || null,
icon: data.icon || null,
sort_order: sortOrder,
is_public: data.is_public ?? true,
});
if (error) throw error;
return created as Category;
if (!created || created.length === 0) throw new Error('Failed to create category');
return created[0] as Category;
}
/**
@@ -102,6 +106,7 @@ export async function updateCategory(
name: data.name,
description: data.description || null,
icon: data.icon || null,
is_public: data.is_public,
});
if (error) throw error;
+2 -1
View File
@@ -132,7 +132,8 @@ export async function createPack(
});
if (error) throw error;
return created as Pack;
if (!created || created.length === 0) throw new Error('Failed to create pack');
return created[0] as Pack;
}
/**
+111 -12
View File
@@ -12,6 +12,7 @@ export interface Question {
intensity: number;
pack_id: string;
created_at: string | null;
deleted_at: string | null;
}
export interface QuestionFormData {
@@ -25,15 +26,21 @@ export interface QuestionFormData {
// =============================================================================
/**
* Fetch all questions for a pack
* Fetch all questions for a pack (excludes soft-deleted by default)
*/
export async function fetchQuestionsForPack(packId: string): Promise<Question[]> {
const { data, error } = await supabase
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
let query = supabase
.from('questions')
.select('*')
.eq('pack_id', packId)
.order('created_at', { ascending: true });
if (!includeDeleted) {
query = query.is('deleted_at', null);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
@@ -53,13 +60,14 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
}
/**
* Fetch question count for a pack
* Fetch question count for a pack (excludes soft-deleted)
*/
export async function fetchQuestionCount(packId: string): Promise<number> {
const { count, error } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('pack_id', packId);
.eq('pack_id', packId)
.is('deleted_at', null);
if (error) throw error;
return count || 0;
@@ -84,7 +92,8 @@ export async function createQuestion(
});
if (error) throw error;
return created as Question;
if (!created || created.length === 0) throw new Error('Failed to create question');
return created[0] as Question;
}
/**
@@ -104,41 +113,131 @@ export async function updateQuestion(
}
/**
* Delete a question
* Soft delete a question (sets deleted_at timestamp)
*/
export async function deleteQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.delete('questions', id);
const { error } = await auditedSupabase.update('questions', id, {
deleted_at: new Date().toISOString(),
});
if (error) throw error;
}
/**
* Delete multiple questions
* Soft delete multiple questions
*/
export async function deleteQuestions(ids: string[]): Promise<void> {
await Promise.all(
ids.map(id => auditedSupabase.delete('questions', id))
ids.map(id => auditedSupabase.update('questions', id, {
deleted_at: new Date().toISOString(),
}))
);
}
/**
* Restore a soft-deleted question
*/
export async function restoreQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, {
deleted_at: null,
});
if (error) throw error;
}
/**
* Bulk create questions (for AI generation)
* Handles inverse_pair_id by grouping pairs and setting inverse_of links
*/
export async function bulkCreateQuestions(
packId: string,
questions: Array<{ text: string; partner_text?: string; intensity: number }>
questions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>
): Promise<Question[]> {
const results: Question[] = [];
// Group questions by inverse_pair_id
const pairMap = new Map<string, typeof questions>();
const standaloneQuestions: typeof questions = [];
for (const q of questions) {
if (q.inverse_pair_id) {
const existing = pairMap.get(q.inverse_pair_id) || [];
existing.push(q);
pairMap.set(q.inverse_pair_id, existing);
} else {
standaloneQuestions.push(q);
}
}
// Insert standalone questions first
for (const q of standaloneQuestions) {
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
inverse_of: null,
});
if (error) throw error;
if (data) results.push(data as Question);
if (data && data.length > 0) results.push(data[0] as Question);
}
// Insert paired questions: primary first, then inverse with inverse_of link
for (const [, pair] of pairMap) {
if (pair.length >= 2) {
// First question is primary
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
inverse_of: null,
});
if (primaryError) throw primaryError;
if (primaryData && primaryData.length > 0) {
results.push(primaryData[0] as Question);
const primaryId = primaryData[0].id;
// Second question is inverse
const inverse = pair[1];
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
inverse_of: primaryId,
});
if (inverseError) throw inverseError;
if (inverseData && inverseData.length > 0) results.push(inverseData[0] as Question);
}
// Handle any additional questions in the pair
for (let i = 2; i < pair.length; i++) {
const extra = pair[i];
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
}
return results;
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SAUCI_MCP_API_KEY=your-secret-api-key
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
EXPOSE 3000
CMD ["node", "dist/index.mjs"]
+2785
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "sauci-mcp-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup src/index.ts src/bridge.ts --format esm --clean",
"start": "node dist/index.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/mcp": "^0.1.1",
"@modelcontextprotocol/sdk": "^1.0.1",
"@supabase/supabase-js": "^2.45.0",
"dotenv": "^16.4.5",
"eventsource": "^4.1.0",
"hono": "^4.6.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.2.4",
"tsx": "^4.19.0",
"typescript": "^5.5.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const args = process.argv.slice(2);
const urlArg = args.find(a => a.startsWith('--url='));
const keyArg = args.find(a => a.startsWith('--key='));
if (!urlArg || !keyArg) {
console.error('Usage: node bridge.js --url=<url> --key=<key>');
process.exit(1);
}
const url = new URL(urlArg.split('=')[1]);
const apiKey = keyArg.split('=')[1];
async function run() {
console.error(`[Bridge] Connecting to ${url.href}`);
console.error(`[Bridge] Using API Key (length: ${apiKey.length})`);
const local = new StdioServerTransport();
const remote = new StreamableHTTPClientTransport(url, {
requestInit: {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Accept': 'application/json, text/event-stream'
}
}
});
// Bridge messages from Claude Desktop to remote server
local.onmessage = async (message) => {
console.error(`[Bridge] -> Remote: ${JSON.stringify(message).slice(0, 100)}...`);
try {
await remote.send(message);
} catch (err) {
console.error('[Bridge] Failed to forward message to remote:', err);
}
};
// Bridge messages from remote server to Claude Desktop
remote.onmessage = async (message) => {
console.error(`[Bridge] <- Remote: ${JSON.stringify(message).slice(0, 100)}...`);
try {
await local.send(message);
} catch (err) {
console.error('[Bridge] Failed to forward message to local:', err);
}
};
remote.onerror = (err) => {
console.error('[Bridge] Remote transport error:', err);
};
local.onerror = (err) => {
console.error('[Bridge] Local transport error:', err);
process.exit(1);
};
local.onclose = () => {
console.error('[Bridge] Local transport closed');
process.exit(0);
};
remote.onclose = () => {
console.error('[Bridge] Remote transport closed');
};
// Start both transports
console.error('[Bridge] Starting remote transport...');
await remote.start();
console.error('[Bridge] Remote transport started');
console.error('[Bridge] Starting local transport...');
await local.start();
console.error('[Bridge] Local transport started - ready for messages');
}
run().catch(err => {
console.error('[Bridge] Fatal error:', err);
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { authMiddleware } from './lib/auth.js';
import { createMcpServer } from './server.js';
const app = new Hono();
app.use('*', logger());
app.use('/mcp', authMiddleware);
app.use('/mcp/*', authMiddleware);
app.get('/health', (c) => c.json({ status: 'ok', service: 'sauci-mcp' }));
// Initialize MCP server with StreamableHTTP transport
const mcpServer = createMcpServer();
// StreamableHTTP endpoint - handles all MCP communication
app.all('/mcp', async (c) => {
return mcpServer.handleRequest(c);
});
const port = Number(process.env.PORT) || 3000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port
});
+29
View File
@@ -0,0 +1,29 @@
import { Context, Next } from 'hono';
export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header('Authorization');
const apiKey = process.env.SAUCI_MCP_API_KEY;
if (!apiKey) {
// If no API key is set in env, allow access (warn in logs)
// Ideally this should default to deny, but for dev ease we might want to warn
console.warn('SAUCI_MCP_API_KEY not set in environment - allowing all requests');
return next();
}
let token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
token = c.req.query('apiKey');
}
if (!token) {
return c.json({ error: 'Unauthorized: Missing or invalid Authorization header or apiKey query param' }, 401);
}
if (token !== apiKey) {
return c.json({ error: 'Unauthorized: Invalid API key' }, 401);
}
return next();
};
+22
View File
@@ -0,0 +1,22 @@
import { createClient } from '@supabase/supabase-js';
import { config } from 'dotenv';
config();
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.warn('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables');
}
export const supabase = createClient(
supabaseUrl || '',
supabaseServiceKey || '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
+37
View File
@@ -0,0 +1,37 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPTransport } from '@hono/mcp';
import { registerContentTools } from './tools/content.js';
import { registerUserTools } from './tools/users.js';
import { registerModerationTools } from './tools/moderation.js';
import { registerCodeTools } from './tools/codes.js';
import { registerFeedbackTools } from './tools/feedback.js';
import { registerAnalyticsTools } from './tools/analytics.js';
import { registerConfigTools } from './tools/config.js';
import { registerAdminTools } from './tools/admin.js';
import { Context } from 'hono';
export function createMcpServer() {
const server = new McpServer({
name: 'sauci-admin',
version: '1.0.0',
});
// Register all tool groups
registerContentTools(server);
registerUserTools(server);
registerModerationTools(server);
registerCodeTools(server);
registerFeedbackTools(server);
registerAnalyticsTools(server);
registerConfigTools(server);
registerAdminTools(server);
return {
handleRequest: async (c: Context) => {
const transport = new StreamableHTTPTransport();
await server.connect(transport);
return transport.handleRequest(c);
}
};
}
+150
View File
@@ -0,0 +1,150 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerAdminTools(server: McpServer) {
server.tool(
'list_admins',
'List all admin users',
{},
async () => {
const { data, error } = await supabase
.from('admin_users')
.select(`
*,
user:user_id (name, email, avatar_url)
`);
if (error) throw new Error(`Failed to list admins: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'add_admin',
'Grant admin access to a user',
{
user_id: z.string(),
role: z.enum(['super_admin', 'pack_creator']).default('pack_creator'),
permissions: z.array(z.string()).optional()
},
async ({ user_id, role, permissions }) => {
const { data, error } = await supabase
.from('admin_users')
.insert({
user_id,
role,
permissions: permissions || []
})
.select()
.single();
if (error) throw new Error(`Failed to add admin: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'admin_users',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_admin_permissions',
'Update admin permissions',
{
id: z.string(), // admin_users ID, not user_id
role: z.enum(['super_admin', 'pack_creator']).optional(),
permissions: z.array(z.string()).optional()
},
async ({ id, role, permissions }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const updates: any = {};
if (role) updates.role = role;
if (permissions) updates.permissions = permissions;
const { data, error } = await supabase
.from('admin_users')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update admin: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'admin_users',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'remove_admin',
'Revoke admin access',
{ id: z.string() }, // admin_users ID
async ({ id }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { error } = await supabase.from('admin_users').delete().eq('id', id);
if (error) throw new Error(`Failed to remove admin: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'admin_users',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Admin ${id} removed successfully` }]
};
}
);
server.tool(
'list_audit_logs',
'View audit logs',
{
limit: z.number().default(20),
offset: z.number().default(0),
table_name: z.string().optional(),
action: z.enum(['INSERT', 'UPDATE', 'DELETE']).optional()
},
async ({ limit, offset, table_name, action }) => {
let query = supabase
.from('audit_logs')
.select('*', { count: 'exact' });
if (table_name) query = query.eq('table_name', table_name);
if (action) query = query.eq('action', action);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list audit logs: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
}
+116
View File
@@ -0,0 +1,116 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { supabase } from '../lib/supabase.js';
export function registerAnalyticsTools(server: McpServer) {
server.tool(
'get_dashboard_stats',
'Get high-level dashboard statistics',
{},
async () => {
// Parallel fetch for counts
const [
{ count: categories },
{ count: packs },
{ count: questions },
{ count: profiles },
{ count: couples }
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('couples').select('*', { count: 'exact', head: true })
]);
return {
content: [{ type: 'text', text: JSON.stringify({
counts: {
categories,
packs,
questions,
profiles,
couples
}
}, null, 2) }]
};
}
);
server.tool(
'get_feature_interests',
'Get feature interest stats',
{},
async () => {
const { data, error } = await supabase.rpc('get_feature_interest_counts');
// Fallback if RPC doesn't exist or fails, query table directly if possible
if (error) {
// Try raw query on feature_interests table
const { data: rawData, error: rawError } = await supabase
.from('feature_interests')
.select('feature_name');
if (rawError) throw new Error(`Failed to get feature interests: ${rawError.message}`);
// Manual aggregation
const counts: Record<string, number> = {};
rawData.forEach((r: any) => {
counts[r.feature_name] = (counts[r.feature_name] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify(counts, null, 2) }]
};
}
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_usage_insights',
'Get usage insights (join reasons, etc)',
{},
async () => {
// Aggregating onboarding data from profiles
const { data, error } = await supabase
.from('profiles')
.select('usage_reason, gender');
if (error) throw new Error(`Failed to get usage insights: ${error.message}`);
const reasons: Record<string, number> = {};
const genders: Record<string, number> = {};
data.forEach((p) => {
if (p.usage_reason) reasons[p.usage_reason] = (reasons[p.usage_reason] || 0) + 1;
if (p.gender) genders[p.gender] = (genders[p.gender] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify({ reasons, genders }, null, 2) }]
};
}
);
server.tool(
'get_question_analytics',
'Get question performance stats',
{},
async () => {
// Limit to top 50 most answered questions
// This is heavy, ideally we'd have an RPC or materialized view
// For now, we'll just return a placeholder or simple query
// Let's get response counts per question
// Using .rpc if available would be best.
// I'll assume we don't have a specific RPC for this yet and do a lightweight check.
return {
content: [{ type: 'text', text: "Question analytics requires complex aggregation. Please use the 'list_questions' tool to inspect specific questions." }]
};
}
);
}
+153
View File
@@ -0,0 +1,153 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerCodeTools(server: McpServer) {
server.tool(
'list_redemption_codes',
'List redemption codes',
{
page: z.number().default(1),
limit: z.number().default(20),
search: z.string().optional()
},
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
.from('redemption_codes')
.select('*', { count: 'exact' });
if (search) {
query = query.or(`code.ilike.%${search}%,description.ilike.%${search}%`);
}
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to list codes: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'generate_code',
'Create a new redemption code',
{
code: z.string().optional(), // If not provided, should generate one
description: z.string().optional(),
max_uses: z.number().default(1),
expires_at: z.string().optional(), // ISO date string
is_active: z.boolean().default(true)
},
async ({ code, description, max_uses, expires_at, is_active }) => {
const finalCode = code || Math.random().toString(36).substring(2, 10).toUpperCase();
const { data, error } = await supabase
.from('redemption_codes')
.insert({
code: finalCode,
description,
max_uses,
expires_at,
is_active,
current_uses: 0
})
.select()
.single();
if (error) throw new Error(`Failed to create code: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'redemption_codes',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'toggle_code_active',
'Enable or disable a redemption code',
{ id: z.string(), is_active: z.boolean() },
async ({ id, is_active }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('redemption_codes')
.update({ is_active })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update code: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'redemption_codes',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Code ${data.code} is now ${is_active ? 'active' : 'inactive'}` }]
};
}
);
server.tool(
'delete_code',
'Delete a redemption code',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { error } = await supabase.from('redemption_codes').delete().eq('id', id);
if (error) throw new Error(`Failed to delete code: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'redemption_codes',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Code ${id} deleted successfully` }]
};
}
);
server.tool(
'list_code_redemptions',
'See who redeemed a code',
{ code_id: z.string() },
async ({ code_id }) => {
const { data, error } = await supabase
.from('code_redemptions')
.select(`
*,
user:user_id (name, email)
`)
.eq('code_id', code_id)
.order('redeemed_at', { ascending: false });
if (error) throw new Error(`Failed to list redemptions: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
}
+123
View File
@@ -0,0 +1,123 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerConfigTools(server: McpServer) {
server.tool(
'get_ai_config',
'Get AI configuration',
{},
async () => {
const { data, error } = await supabase
.from('ai_config')
.select('*')
.single();
if (error) throw new Error(`Failed to get AI config: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_ai_config',
'Update AI configuration',
{
openrouter_api_key: z.string().optional(),
default_model: z.string().optional(),
classifier_enabled: z.boolean().optional(),
classifier_model: z.string().optional(),
classifier_prompt: z.string().optional(),
council_enabled: z.boolean().optional()
},
async (updates) => {
// Get current ID (singleton table usually has 1 row, but need ID for update)
const { data: current, error: fetchError } = await supabase
.from('ai_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
.from('ai_config')
.update(updates)
.eq('id', current.id)
.select()
.single();
if (error) throw new Error(`Failed to update AI config: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'ai_config',
record_id: current.id,
old_values: current,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_app_config',
'Get app configuration',
{},
async () => {
const { data, error } = await supabase
.from('app_config')
.select('*')
.single();
if (error) throw new Error(`Failed to get app config: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_app_config',
'Update app configuration',
{
answer_gap_threshold: z.number().optional(),
daily_response_limit: z.number().optional()
},
async (updates) => {
const { data: current, error: fetchError } = await supabase
.from('app_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
.from('app_config')
.update(updates)
.eq('id', current.id)
.select()
.single();
if (error) throw new Error(`Failed to update app config: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'app_config',
record_id: current.id,
old_values: current,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
}
+371
View File
@@ -0,0 +1,371 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerContentTools(server: McpServer) {
// --- Categories ---
server.tool(
'list_categories',
'List all categories with pack counts',
{},
async () => {
const { data, error } = await supabase
.from('categories')
.select(`
*,
question_packs (count)
`)
.order('sort_order');
if (error) throw new Error(`Failed to list categories: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_category',
'Create a new category',
{
name: z.string(),
description: z.string().optional(),
icon: z.string(),
sort_order: z.number().optional(),
is_public: z.boolean().default(false)
},
async ({ name, description, icon, sort_order, is_public }) => {
const { data, error } = await supabase
.from('categories')
.insert({ name, description, icon, sort_order: sort_order ?? 0, is_public })
.select()
.single();
if (error) throw new Error(`Failed to create category: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'categories',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_category',
'Update a category',
{
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
is_public: z.boolean().optional()
},
async ({ id, ...updates }) => {
// Get old values for audit
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { data, error } = await supabase
.from('categories')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update category: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'categories',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_category',
'Delete a category',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { error } = await supabase
.from('categories')
.delete()
.eq('id', id);
if (error) throw new Error(`Failed to delete category: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'categories',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Category ${id} deleted successfully` }]
};
}
);
// --- Packs ---
server.tool(
'list_packs',
'List packs for a category or all packs',
{ category_id: z.string().optional() },
async ({ category_id }) => {
let query = supabase
.from('question_packs')
.select(`
*,
questions (count)
`)
.order('sort_order');
if (category_id) {
query = query.eq('category_id', category_id);
}
const { data, error } = await query;
if (error) throw new Error(`Failed to list packs: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_pack',
'Create a new question pack',
{
name: z.string(),
category_id: z.string(),
description: z.string().optional(),
is_premium: z.boolean().default(false),
is_public: z.boolean().default(false),
is_explicit: z.boolean().default(false),
sort_order: z.number().optional()
},
async (input) => {
const { data, error } = await supabase
.from('question_packs')
.insert({ ...input, sort_order: input.sort_order ?? 0 })
.select()
.single();
if (error) throw new Error(`Failed to create pack: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'question_packs',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_pack',
'Update a question pack',
{
id: z.string(),
name: z.string().optional(),
category_id: z.string().optional(),
description: z.string().optional(),
is_premium: z.boolean().optional(),
is_public: z.boolean().optional(),
is_explicit: z.boolean().optional(),
sort_order: z.number().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { data, error } = await supabase
.from('question_packs')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update pack: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'question_packs',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_pack',
'Delete a question pack',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { error } = await supabase.from('question_packs').delete().eq('id', id);
if (error) throw new Error(`Failed to delete pack: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'question_packs',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Pack ${id} deleted successfully` }]
};
}
);
// --- Questions ---
server.tool(
'list_questions',
'List questions for a pack',
{ pack_id: z.string() },
async ({ pack_id }) => {
const { data, error } = await supabase
.from('questions')
.select('*')
.eq('pack_id', pack_id)
.order('created_at'); // Questions don't strictly have a sort_order usually
if (error) throw new Error(`Failed to list questions: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_question',
'Create a single question',
{
pack_id: z.string(),
text: z.string(),
partner_text: z.string().optional(),
intensity: z.number().min(1).max(5).default(1)
},
async (input) => {
const { data, error } = await supabase
.from('questions')
.insert(input)
.select()
.single();
if (error) throw new Error(`Failed to create question: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'questions',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_question',
'Update a question',
{
id: z.string(),
text: z.string().optional(),
partner_text: z.string().optional(),
intensity: z.number().min(1).max(5).optional(),
pack_id: z.string().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('questions').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Question not found: ${id}`);
const { data, error } = await supabase
.from('questions')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update question: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'questions',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_questions',
'Delete one or more questions',
{ ids: z.array(z.string()) },
async ({ ids }) => {
const { data: oldData } = await supabase.from('questions').select('*').in('id', ids);
const { error } = await supabase.from('questions').delete().in('id', ids);
if (error) throw new Error(`Failed to delete questions: ${error.message}`);
// Log audit for each deleted question
if (oldData) {
for (const record of oldData) {
await logAudit({
action: 'DELETE',
table_name: 'questions',
record_id: record.id,
old_values: record
});
}
}
return {
content: [{ type: 'text', text: `${ids.length} questions deleted successfully` }]
};
}
);
}
+124
View File
@@ -0,0 +1,124 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerFeedbackTools(server: McpServer) {
server.tool(
'list_feedback',
'List feedback with filters',
{
type: z.enum(['bug', 'feature_request', 'general', 'question']).optional(),
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed']).optional(),
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ type, status, limit, offset }) => {
let query = supabase
.from('feedback')
.select(`
*,
user:user_id (name, email)
`, { count: 'exact' });
if (type) query = query.eq('type', type);
if (status) query = query.eq('status', status);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list feedback: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'get_feedback_detail',
'Get full feedback details',
{ id: z.string() },
async ({ id }) => {
const { data, error } = await supabase
.from('feedback')
.select(`
*,
user:user_id (name, email),
question:question_id (text)
`)
.eq('id', id)
.single();
if (error) throw new Error(`Failed to get feedback detail: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_feedback_status',
'Update feedback status',
{
id: z.string(),
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed'])
},
async ({ id, status }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('feedback')
.update({ status })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update status: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'feedback',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Feedback ${id} status updated to ${status}` }]
};
}
);
server.tool(
'add_feedback_notes',
'Add admin notes to feedback',
{ id: z.string(), notes: z.string() },
async ({ id, notes }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('feedback')
.update({ admin_notes: notes })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update notes: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'feedback',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Notes added to feedback ${id}` }]
};
}
);
}
+147
View File
@@ -0,0 +1,147 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerModerationTools(server: McpServer) {
server.tool(
'list_flagged_messages',
'List messages flagged by AI for review',
{
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ limit, offset }) => {
const { data, error, count } = await supabase
.from('messages')
.select('*', { count: 'exact' })
.eq('moderation_status', 'flagged')
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list flagged messages: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'list_message_reports',
'List user-submitted reports',
{
status: z.enum(['pending', 'reviewed', 'dismissed']).default('pending'),
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ status, limit, offset }) => {
const { data, error, count } = await supabase
.from('message_reports')
.select(`
*,
message:message_id (*),
reporter:reporter_id (name, email)
`, { count: 'exact' })
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list reports: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'mark_message_safe',
'Mark a flagged message as safe',
{ message_id: z.string() },
async ({ message_id }) => {
const { data: oldData } = await supabase.from('messages').select('*').eq('id', message_id).single();
const { data, error } = await supabase
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', message_id)
.select()
.single();
if (error) throw new Error(`Failed to update message: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'messages',
record_id: message_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Message ${message_id} marked as safe` }]
};
}
);
server.tool(
'dismiss_report',
'Dismiss a user report',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
.from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
.select()
.single();
if (error) throw new Error(`Failed to dismiss report: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'message_reports',
record_id: report_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Report ${report_id} dismissed` }]
};
}
);
server.tool(
'mark_report_reviewed',
'Mark a report as reviewed (action taken)',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
.from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
.select()
.single();
if (error) throw new Error(`Failed to update report: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'message_reports',
record_id: report_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Report ${report_id} marked as reviewed` }]
};
}
);
}
+217
View File
@@ -0,0 +1,217 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerUserTools(server: McpServer) {
server.tool(
'list_users',
'List users with pagination and search',
{
page: z.number().default(1),
limit: z.number().default(20),
search: z.string().optional()
},
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
.from('profiles')
.select('*', { count: 'exact' });
if (search) {
query = query.or(`name.ilike.%${search}%,email.ilike.%${search}%`);
}
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to list users: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count, page, limit }, null, 2) }]
};
}
);
server.tool(
'get_user_detail',
'Get full user profile details',
{ user_id: z.string() },
async ({ user_id }) => {
const { data, error } = await supabase
.from('profiles')
.select(`
*,
couple:couple_id (*)
`)
.eq('id', user_id)
.single();
if (error) throw new Error(`Failed to get user details: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_responses',
'Get user question responses',
{ user_id: z.string(), limit: z.number().default(50) },
async ({ user_id, limit }) => {
const { data, error } = await supabase
.from('responses')
.select(`
*,
question:question_id (text, intensity)
`)
.eq('user_id', user_id)
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw new Error(`Failed to get responses: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_matches',
'Get user matches',
{ user_id: z.string() },
async ({ user_id }) => {
// First get couple_id
const { data: profile } = await supabase.from('profiles').select('couple_id').eq('id', user_id).single();
if (!profile?.couple_id) {
return { content: [{ type: 'text', text: 'User is not in a couple' }] };
}
const { data, error } = await supabase
.from('matches')
.select(`
*,
question:question_id (text)
`)
.eq('couple_id', profile.couple_id)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get matches: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_media',
'List user uploaded media',
{ user_id: z.string() },
async ({ user_id }) => {
// This is tricky as media is in storage, but usually referenced in messages
// We'll search messages table for media_path where user_id matches
const { data, error } = await supabase
.from('messages')
.select('id, media_path, created_at, match_id')
.eq('user_id', user_id)
.not('media_path', 'is', null)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get user media: ${error.message}`);
// Generate public URLs for media
const mediaWithUrls = data.map(m => {
const { data: urlData } = supabase.storage
.from('chat-media')
.getPublicUrl(m.media_path);
return {
...m,
url: urlData.publicUrl
};
});
return {
content: [{ type: 'text', text: JSON.stringify(mediaWithUrls, null, 2) }]
};
}
);
server.tool(
'get_match_chat',
'View chat messages for a match',
{ match_id: z.string(), limit: z.number().default(50) },
async ({ match_id, limit }) => {
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('match_id', match_id)
.order('created_at', { ascending: false }) // Newest first
.limit(limit);
if (error) throw new Error(`Failed to get chat: ${error.message}`);
// Reverse to show chronological order in output if desired, but JSON is fine
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'gift_premium',
'Give user premium access',
{
user_id: z.string(),
days: z.number().default(30),
reason: z.string().optional() // For audit log
},
async ({ user_id, days }) => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
const { data: oldData } = await supabase.from('profiles').select('is_premium').eq('id', user_id).single();
// We update profiles table directly or insert into subscriptions?
// Research said `profiles.is_premium` is the flag, but also mentioned `subscriptions` table.
// Usually `subscriptions` drives `is_premium` via webhooks, but for gifting we might just set the flag
// OR insert a manual subscription.
// The admin dashboard uses `auditedSupabase.insert('subscriptions', {...})`.
const { data, error } = await supabase
.from('subscriptions')
.insert({
user_id,
product_id: 'admin_gift',
status: 'active',
expires_at: expiresAt.toISOString(),
is_sandbox: false
})
.select()
.single();
if (error) throw new Error(`Failed to gift premium: ${error.message}`);
// Also ensure profile is updated (if not handled by triggers)
await supabase.from('profiles').update({ is_premium: true }).eq('id', user_id);
await logAudit({
action: 'INSERT',
table_name: 'subscriptions',
record_id: data.id,
new_values: data,
old_values: { reason: 'Premium Gift', old_is_premium: oldData?.is_premium }
});
return {
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt.toISOString()}` }]
};
}
);
}
+81
View File
@@ -0,0 +1,81 @@
import { supabase } from '../lib/supabase.js';
export interface AuditLogEntry {
action: 'INSERT' | 'UPDATE' | 'DELETE';
table_name: string;
record_id?: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
admin_user_id?: string;
}
let cachedSystemAdminId: string | null = null;
async function getSystemAdminId(): Promise<string | null> {
if (cachedSystemAdminId) return cachedSystemAdminId;
// Try to find a super_admin user
const { data, error } = await supabase
.from('admin_users')
.select('user_id')
.eq('role', 'super_admin')
.limit(1)
.maybeSingle();
if (data?.user_id) {
cachedSystemAdminId = data.user_id;
return data.user_id;
}
// Fallback: any admin user
const { data: anyAdmin } = await supabase
.from('admin_users')
.select('user_id')
.limit(1)
.maybeSingle();
if (anyAdmin?.user_id) {
cachedSystemAdminId = anyAdmin.user_id;
return anyAdmin.user_id;
}
return null;
}
/**
* Log an admin action to the audit_logs table
*/
export async function logAudit(entry: AuditLogEntry) {
try {
let adminUserId = entry.admin_user_id;
if (!adminUserId) {
adminUserId = await getSystemAdminId() || undefined;
}
if (!adminUserId) {
console.warn('Cannot log audit entry: No admin user found to attribute action to');
return;
}
// Ensure values are plain objects, not Supabase response objects
const cleanOldValues = entry.old_values ? JSON.parse(JSON.stringify(entry.old_values)) : null;
const cleanNewValues = entry.new_values ? JSON.parse(JSON.stringify(entry.new_values)) : null;
const { error } = await supabase.from('audit_logs').insert({
action: entry.action,
table_name: entry.table_name,
record_id: entry.record_id || '00000000-0000-0000-0000-000000000000', // Fallback UUID if needed
old_values: cleanOldValues,
new_values: cleanNewValues,
admin_role: 'super_admin', // MCP acts as super admin
admin_user_id: adminUserId
});
if (error) {
console.error('Failed to write audit log:', error);
}
} catch (err) {
console.error('Error in logAudit:', err);
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17
versionCode 21
versionName "1.0.0"
}
signingConfigs {
+2 -2
View File
@@ -16,7 +16,7 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"buildNumber": "24",
"buildNumber": "25",
"usesAppleSignIn": true,
"infoPlist": {
"UIBackgroundModes": [
@@ -176,7 +176,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 20,
"versionCode": 21,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
+53 -137
View File
@@ -10,8 +10,9 @@ import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics";
import type { MatchWithQuestion } from "../../src/types";
import type { MatchWithQuestion, AnswerType } from "../../src/types";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -143,8 +144,7 @@ export default function AppLayout() {
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
const matchModalAnim = useRef(new Animated.Value(0)).current;
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false);
@@ -381,10 +381,42 @@ export default function AppLayout() {
Events.milestoneMatch(currentMatchCount + 1);
}
// Add to store and show notification
// Add to store
addMatch(matchWithQuestion);
setMatchNotification(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown");
// Fetch individual responses to show what each person said
const { data: responses } = await supabase
.from("responses")
.select("user_id, answer")
.eq("question_id", matchWithQuestion.question_id)
.eq("couple_id", user.couple_id);
let userResponse: AnswerType | undefined;
let partnerResponse: AnswerType | undefined;
if (responses && responses.length >= 2) {
for (const r of responses) {
if (r.user_id === user.id) {
userResponse = r.answer as AnswerType;
} else {
partnerResponse = r.answer as AnswerType;
}
}
}
// Get partner name from auth store
const { partner } = useAuthStore.getState();
// Show notification with response data
setMatchNotification({
id: matchWithQuestion.id,
match_type: matchWithQuestion.match_type,
question: matchWithQuestion.question,
userResponse,
partnerResponse,
partnerName: partner?.name,
});
}
}
)
@@ -509,27 +541,17 @@ export default function AppLayout() {
};
}, [lastMessage]);
// Animate match modal when notification changes
useEffect(() => {
if (matchNotification) {
Animated.spring(matchModalAnim, {
toValue: 1,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
}
}, [matchNotification, matchModalAnim]);
const dismissNotification = useCallback(() => {
Animated.timing(matchModalAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
setMatchNotification(null);
});
}, [matchModalAnim]);
}, []);
const handleMatchChat = useCallback(() => {
if (matchNotification) {
const matchId = matchNotification.id;
setMatchNotification(null);
router.push(`/(app)/chat/${matchId}`);
}
}, [matchNotification, router]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
@@ -786,53 +808,13 @@ export default function AppLayout() {
})}
</Animated.View>
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
{matchNotification && (
<Animated.View
style={[
styles.matchOverlay,
{
opacity: matchModalAnim,
},
]}
>
<Pressable style={styles.matchOverlayBackground} onPress={dismissNotification} />
<Animated.View
style={[
styles.modalContent,
{
transform: [
{
scale: matchModalAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1],
}),
},
],
},
]}
>
<View style={styles.modalHeader}>
<Ionicons name="heart" size={48} color={colors.primary} />
<Text style={styles.modalTitle}>It's a Match!</Text>
</View>
<Text style={styles.modalSubtitle}>You both agreed on:</Text>
<Text style={styles.modalQuestion}>
"{matchNotification?.question?.text}"
</Text>
<View style={styles.matchTypeBadge}>
<Text style={styles.matchTypeText}>
{matchNotification?.match_type === 'yes_yes' ? 'YES + YES' :
matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' :
'MAYBE + MAYBE'}
</Text>
</View>
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
<Text style={styles.dismissButtonText}>Nice!</Text>
</Pressable>
</Animated.View>
</Animated.View>
)}
{/* Match Notification Modal - Premium styling with response details */}
<MatchNotificationModal
visible={!!matchNotification}
match={matchNotification}
onDismiss={dismissNotification}
onChat={handleMatchChat}
/>
{/* Message Toast Notification */}
{lastMessage && (
@@ -875,72 +857,6 @@ const styles = StyleSheet.create({
alignItems: "center",
backgroundColor: colors.background,
},
matchOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
elevation: 9999,
},
matchOverlayBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
modalContent: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.xl,
alignItems: 'center',
width: '90%',
maxWidth: 340,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
modalHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
},
modalTitle: {
...typography.title1,
color: colors.text,
marginTop: spacing.md,
},
modalSubtitle: {
...typography.body,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
modalQuestion: {
...typography.headline,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.lg,
},
matchTypeBadge: {
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
marginBottom: spacing.lg,
},
matchTypeText: {
...typography.caption1,
color: colors.primary,
fontWeight: '600',
},
dismissButton: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.lg,
minWidth: 120,
},
dismissButtonText: {
...typography.headline,
color: colors.text,
textAlign: 'center',
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
+2 -2
View File
@@ -22,7 +22,7 @@ const ACCENT_COLOR = colors.primary;
export default function HomeScreen() {
const { user, partner, couple } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
const { enabledPackIds, fetchPacks } = usePacksStore();
const { width } = useWindowDimensions();
@@ -146,7 +146,7 @@ export default function HomeScreen() {
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
/>
</View>
<Text style={styles.statNumber}>{matches.length}</Text>
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text>
</View>
</TouchableOpacity>
+157 -4
View File
@@ -18,6 +18,7 @@ import Animated, {
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { SwipeableMatchItem } from "../../src/components/matches";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import { MatchesTutorial } from "../../src/components/tutorials";
@@ -37,7 +38,23 @@ const HEADER_SCROLL_DISTANCE = 100;
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore();
const {
matches,
fetchMatches,
markAllAsSeen,
isLoading,
hasMore,
isLoadingMore,
totalCount,
// Archive state and methods
archivedMatches,
showArchived,
toggleShowArchived,
archiveMatch,
unarchiveMatch,
isLoadingArchived,
fetchArchivedMatches,
} = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
@@ -132,6 +149,11 @@ export default function MatchesScreen() {
}, [matches.length]);
const renderItem = ({ item, index }: { item: any; index: number }) => {
// Skip rendering if question was deleted
if (!item.question) {
return null;
}
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
@@ -150,8 +172,20 @@ export default function MatchesScreen() {
const isYesYes = item.match_type === "yes_yes";
const handleArchive = async () => {
if (showArchived) {
await unarchiveMatch(item.id);
} else {
await archiveMatch(item.id);
}
};
return (
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
<SwipeableMatchItem
onArchive={handleArchive}
isArchived={showArchived}
>
<TouchableOpacity
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
@@ -228,6 +262,7 @@ export default function MatchesScreen() {
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity>
</SwipeableMatchItem>
</Animated.View>
);
};
@@ -296,7 +331,7 @@ export default function MatchesScreen() {
</View>
<AnimatedFlatList
data={matches}
data={showArchived ? archivedMatches : matches}
renderItem={renderItem}
keyExtractor={(item: any) => item.id}
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
@@ -328,15 +363,86 @@ export default function MatchesScreen() {
{/* Decorative separator */}
<DecorativeSeparator variant="gold" />
{/* Filter toggle */}
<View style={styles.filterContainer}>
<TouchableOpacity
style={[styles.filterTab, !showArchived && styles.filterTabActive]}
onPress={() => showArchived && toggleShowArchived()}
activeOpacity={0.7}
>
<Ionicons
name="heart"
size={14}
color={!showArchived ? ACCENT : colors.textTertiary}
/>
<Text style={[styles.filterTabText, !showArchived && styles.filterTabTextActive]}>
Active
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterTab, showArchived && styles.filterTabActive]}
onPress={() => {
if (!showArchived) {
toggleShowArchived();
}
}}
activeOpacity={0.7}
>
{isLoadingArchived ? (
<ActivityIndicator size="small" color={ACCENT} />
) : (
<>
<Ionicons
name="archive-outline"
size={14}
color={showArchived ? ACCENT : colors.textTertiary}
/>
<Text style={[styles.filterTabText, showArchived && styles.filterTabTextActive]}>
Archived
</Text>
</>
)}
</TouchableOpacity>
</View>
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="heart" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>{totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}</Text>
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>
{showArchived
? `${archivedMatches.length} ARCHIVED`
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
}
</Text>
</View>
</View>
</Animated.View>
}
ListEmptyComponent={
showArchived ? (
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* Archive icon */}
<View style={styles.emptyIconContainer}>
<Ionicons name="archive-outline" size={48} color={ACCENT} />
</View>
{/* Description */}
<Text style={styles.emptyTitle}>No Archived Matches</Text>
<Text style={styles.emptyDescription}>
Matches you archive will appear here. Swipe left on any match to archive it.
</Text>
<GlassButton
onPress={() => toggleShowArchived()}
style={{ marginTop: spacing.lg }}
>
View Active Matches
</GlassButton>
</Animated.View>
) : (
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
@@ -372,6 +478,7 @@ export default function MatchesScreen() {
Start Swiping
</GlassButton>
</Animated.View>
)
}
/>
</View>
@@ -478,6 +585,36 @@ const styles = StyleSheet.create({
color: colors.text,
textAlign: 'center',
},
// Filter toggle
filterContainer: {
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: radius.lg,
padding: 4,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
},
filterTab: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
gap: spacing.xs,
},
filterTabActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
filterTabText: {
...typography.caption1,
color: colors.textTertiary,
fontWeight: '500',
},
filterTabTextActive: {
color: ACCENT,
fontWeight: '600',
},
countBadgePremium: {
flexDirection: 'row',
alignItems: 'center',
@@ -628,6 +765,22 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
+34 -6
View File
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user } = useAuthStore();
const router = useRouter();
const params = useLocalSearchParams();
@@ -123,7 +123,7 @@ export default function MyAnswersScreen() {
}
}, [isLoading, isLoadingMore, hasMore]);
const sections = groupResponses(responses, groupBy);
const sections = groupResponses(responses, groupBy, dateSortOrder);
const handleEditPress = (response: ResponseWithQuestion) => {
setSelectedResponse(response);
@@ -164,16 +164,39 @@ export default function MyAnswersScreen() {
);
};
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => {
const isActive = groupBy === option;
const isDateButton = option === "date";
const showChevron = isDateButton && isActive;
const handlePress = () => {
if (isDateButton && isActive) {
// Toggle sort order when date is already selected
toggleDateSortOrder();
} else {
setGroupBy(option);
}
};
return (
<TouchableOpacity
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
onPress={() => setGroupBy(option)}
style={[styles.groupByButton, isActive && styles.groupByButtonActive]}
onPress={handlePress}
>
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
<Text style={[styles.groupByText, isActive && styles.groupByTextActive]}>
{label}
</Text>
{showChevron && (
<Ionicons
name={dateSortOrder === "newest" ? "chevron-down" : "chevron-up"}
size={14}
color={ACCENT}
style={styles.sortChevron}
/>
)}
</TouchableOpacity>
);
};
if (!user) {
return (
@@ -441,6 +464,8 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
},
groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
@@ -461,6 +486,9 @@ const styles = StyleSheet.create({
color: ACCENT,
fontWeight: "600",
},
sortChevron: {
marginLeft: spacing.xs,
},
// Section header
sectionHeader: {
paddingVertical: spacing.sm,
+26 -12
View File
@@ -42,15 +42,15 @@ const getIntensityDisplay = (pack: QuestionPack) => {
const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null;
const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null;
const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null;
const fallback = avg ?? (pack.is_explicit ? 5 : 2);
const flameCount = max ?? avg ?? (pack.is_explicit ? 5 : 2);
const label = min && max
? min === max
? INTENSITY_LABELS[min]
: `${INTENSITY_LABELS[min]} - ${INTENSITY_LABELS[max]}`
: INTENSITY_LABELS[flameCount];
const minCount = min ?? max ?? fallback;
const maxCount = max ?? min ?? fallback;
const label = minCount === maxCount
? INTENSITY_LABELS[minCount]
: `${INTENSITY_LABELS[minCount]} - ${INTENSITY_LABELS[maxCount]}`;
return { flameCount, label };
return { minCount, maxCount, label };
};
@@ -100,14 +100,19 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
</Text>
<View style={styles.packIntensityRow}>
<View style={styles.packIntensityFlames}>
{Array.from({ length: 5 }).map((_, index) => (
{Array.from({ length: 5 }).map((_, index) => {
const level = index + 1;
const isInRange = level >= intensityDisplay.minCount && level <= intensityDisplay.maxCount;
return (
<Ionicons
key={`intensity-${pack.id}-${index}`}
name={index < intensityDisplay.flameCount ? "flame" : "flame-outline"}
name={isInRange ? "flame" : "flame-outline"}
size={12}
color={index < intensityDisplay.flameCount ? colors.primary : colors.textTertiary}
color={isInRange ? colors.primary : colors.textTertiary}
/>
))}
);
})}
</View>
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
</View>
@@ -462,7 +467,15 @@ export default function QuestionPacksScreen() {
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
<CategorySection
category={{ id: "uncategorized", name: "Other", description: null, icon: "📁", sort_order: 999, created_at: "" }}
category={{
id: "uncategorized",
name: "Other",
description: null,
icon: "📁",
sort_order: 999,
created_at: "",
is_public: true
}}
packs={packsByCategory.get("uncategorized") || []}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
@@ -727,6 +740,7 @@ const styles = StyleSheet.create({
},
packIntensityFlames: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
packIntensityText: {
+332 -13
View File
@@ -24,9 +24,18 @@ import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // P
import { SwipeTutorial } from "../../src/components/tutorials";
import { QuestionFeedbackModal } from "../../src/components/feedback";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Paywall } from "../../src/components/paywall";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
interface DailyLimitInfo {
responses_today: number;
limit_value: number;
remaining: number;
reset_at: string;
is_blocked: boolean;
}
export default function SwipeScreen() {
const params = useLocalSearchParams();
// Normalize packId - useLocalSearchParams can return string | string[]
@@ -38,6 +47,8 @@ export default function SwipeScreen() {
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
const { partner, couple } = useAuthStore();
const hasTrackedExhausted = useRef(false);
@@ -49,6 +60,40 @@ export default function SwipeScreen() {
// Progress bar shimmer animation
const shimmerPosition = useSharedValue(-1);
// Countdown for daily limit reset
const [countdown, setCountdown] = useState<string>("");
useEffect(() => {
if (!dailyLimitInfo?.is_blocked || !dailyLimitInfo.reset_at) {
setCountdown("");
return;
}
const updateCountdown = () => {
const now = new Date();
const reset = new Date(dailyLimitInfo.reset_at);
const diff = reset.getTime() - now.getTime();
if (diff <= 0) {
setCountdown("00:00:00");
// Refresh questions when timer expires
fetchQuestions();
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, [dailyLimitInfo?.is_blocked, dailyLimitInfo?.reset_at]);
useEffect(() => {
// Progress bar shimmer sweep - 2.5 second cycle
shimmerPosition.value = withRepeat(
@@ -139,16 +184,48 @@ export default function SwipeScreen() {
}
};
// Calculate effective total questions (accounting for gap limit)
const effectiveTotal = (() => {
if (!gapInfo || gapInfo.threshold === 0) {
// No gap limit active, show all questions
return questions.length;
// Check if user has reached daily response limit
const checkDailyLimit = async (): Promise<boolean> => {
try {
const { data, error } = await supabase.rpc('get_daily_response_limit_status');
if (error) {
console.error('Error checking daily limit:', error);
setDailyLimitInfo(null);
return false;
}
// remaining = how many more questions until blocked
if (data && data.length > 0) {
const result = data[0];
setDailyLimitInfo(result);
return result.is_blocked;
}
setDailyLimitInfo(null);
return false;
} catch (error) {
console.error('Failed to check daily limit:', error);
setDailyLimitInfo(null);
return false;
}
};
// Calculate effective total questions (accounting for gap limit AND daily limit)
const effectiveTotal = (() => {
let total = questions.length;
// Apply gap limit if active
if (gapInfo && gapInfo.threshold > 0) {
const remaining = gapInfo.threshold - gapInfo.unanswered;
// effective total = current position + remaining (capped by actual questions)
return Math.min(questions.length, currentIndex + Math.max(0, remaining));
total = Math.min(total, currentIndex + Math.max(0, remaining));
}
// Apply daily limit if active
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0 && !dailyLimitInfo.is_blocked) {
total = Math.min(total, currentIndex + dailyLimitInfo.remaining);
}
return total;
})();
// Track when all questions are exhausted
@@ -212,14 +289,14 @@ export default function SwipeScreen() {
// Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
// Always check answer gap when user has partner and not viewing specific pack
// This gives us the gap info for accurate counter display
// Always check answer gap and daily limit when user has partner and not viewing specific pack
if (partner && !packId) {
await checkAnswerGap();
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
} else {
// Reset blocked state if no partner or viewing specific pack
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
}
// Server provides ordering (daily seeded random with priority)
@@ -264,8 +341,19 @@ export default function SwipeScreen() {
console.error("Submit response error:", error);
} else {
Events.questionAnswered(answer, question.pack_id);
// Check if we've hit the answer gap threshold
await checkAnswerGap();
// Increment local daily count if we have limit info
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
setDailyLimitInfo(prev => prev ? {
...prev,
responses_today: prev.responses_today + 1,
remaining: Math.max(0, prev.remaining - 1),
is_blocked: prev.responses_today + 1 >= prev.limit_value
} : null);
}
// Check both gap and daily limit
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
} catch (error) {
console.error("Failed to submit response", error);
@@ -350,6 +438,117 @@ export default function SwipeScreen() {
);
}
// Show "daily limit reached" screen when user has exhausted their daily quota
if (dailyLimitInfo?.is_blocked) {
const LIMIT_ACCENT = colors.premium.gold;
return (
<GradientBackground>
{/* Ambient Orbs - Gold/warm glow */}
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbBottom, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={['rgba(212, 175, 55, 0.15)', 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.waitingContent}
>
{/* Icon with glow effect */}
<View style={styles.dailyLimitIconWrapper}>
<View style={styles.dailyLimitIconGlow} />
<View style={styles.dailyLimitIconContainer}>
<Ionicons name="hourglass-outline" size={36} color={LIMIT_ACCENT} />
</View>
</View>
{/* Title section */}
<Text style={styles.dailyLimitLabel}>DAILY LIMIT REACHED</Text>
<Text style={styles.dailyLimitTitle}>Take a Breather</Text>
<DecorativeSeparator variant="gold" />
{/* Countdown timer - prominent display */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.countdownContainer}
>
<Text style={styles.countdownLabel}>REFRESHES IN</Text>
<View style={styles.countdownBadge}>
<Ionicons name="timer-outline" size={20} color={LIMIT_ACCENT} />
<Text style={styles.countdownText}>{countdown}</Text>
</View>
</Animated.View>
{/* Description */}
<Text style={styles.dailyLimitDescription}>
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
Let the anticipation build while you wait.
</Text>
{/* Feature hints - vertical list */}
<View style={styles.waitingFeatures}>
<View style={styles.waitingFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={colors.premium.gold} />
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="sparkles" size={16} color={colors.premium.gold} />
<Text style={styles.waitingFeatureText}>Fresh questions tomorrow</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="infinite-outline" size={16} color={colors.premium.gold} />
<Text style={styles.waitingFeatureText}>Go unlimited with Premium</Text>
</View>
</View>
{/* Premium upsell button */}
<Text style={styles.premiumUpsellText}>Want to keep exploring?</Text>
<TouchableOpacity
onPress={() => setShowPaywall(true)}
style={styles.premiumButton}
activeOpacity={0.85}
>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.premiumButtonGradient}
/>
<View style={styles.premiumButtonContent}>
<Ionicons name="infinite" size={18} color="#000" />
<Text style={styles.premiumButtonText}>Unlock Unlimited</Text>
</View>
</TouchableOpacity>
</Animated.View>
</View>
{/* Paywall Modal */}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
setShowPaywall(false);
// Refresh to clear limit status
fetchQuestions();
}}
/>
</GradientBackground>
);
}
// Show "waiting for partner" message when user is too far ahead
if (isBlocked && gapInfo) {
const WAITING_ACCENT = colors.premium.rose;
@@ -656,6 +855,17 @@ export default function SwipeScreen() {
questionId={feedbackQuestion?.id || ''}
questionText={feedbackQuestion?.text || ''}
/>
{/* Paywall Modal */}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
setShowPaywall(false);
// Refresh to clear limit status
fetchQuestions();
}}
/>
</GradientBackground>
);
}
@@ -889,4 +1099,113 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginBottom: spacing.xs,
},
// Daily limit screen styles
dailyLimitOrbTop: {
top: 80,
right: -60,
},
dailyLimitOrbBottom: {
bottom: 200,
left: -80,
},
dailyLimitIconWrapper: {
position: 'relative',
marginBottom: spacing.xl,
},
dailyLimitIconGlow: {
position: 'absolute',
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: 'rgba(212, 175, 55, 0.15)',
top: -24,
left: -24,
},
dailyLimitIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1.5,
borderColor: 'rgba(212, 175, 55, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
dailyLimitLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.xs,
},
dailyLimitTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
countdownContainer: {
alignItems: 'center',
marginBottom: spacing.xl,
},
countdownLabel: {
...typography.caption2,
letterSpacing: 1.5,
color: colors.textTertiary,
marginBottom: spacing.sm,
},
countdownBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.25)',
gap: spacing.sm,
},
countdownText: {
...typography.title2,
fontWeight: '700',
color: colors.premium.gold,
fontVariant: ['tabular-nums'],
letterSpacing: 2,
},
dailyLimitDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.lg,
lineHeight: 24,
paddingHorizontal: spacing.md,
},
premiumUpsellText: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
marginBottom: spacing.md,
},
premiumButton: {
position: 'relative',
borderRadius: radius.lg,
overflow: 'hidden',
...shadows.md,
},
premiumButtonGradient: {
...StyleSheet.absoluteFillObject,
},
premiumButtonContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
gap: spacing.sm,
},
premiumButtonText: {
...typography.body,
fontWeight: '600',
color: '#000',
},
});
+1 -1
View File
@@ -71,7 +71,7 @@ export default function PackDetailsScreen() {
try {
const { data, error } = await supabase
.from("questions")
.select("*")
.select("id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, created_at")
.eq("pack_id", id)
.order("created_at");
+3 -3
View File
@@ -47,7 +47,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20</string>
<string>25</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
@@ -60,7 +60,7 @@
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.</string>
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
<key>NSFaceIDUsageDescription</key>
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
<key>NSMicrophoneUsageDescription</key>
@@ -68,7 +68,7 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.</string>
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
@@ -0,0 +1,284 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Text, View, Platform } from 'react-native';
import { GlassButton } from '@/components/ui/GlassButton';
// Store the mock for expo-haptics so we can check calls
const mockImpactAsync = jest.fn();
// Mock expo-haptics - must happen before import
jest.mock('expo-haptics', () => ({
impactAsync: mockImpactAsync,
ImpactFeedbackStyle: {
Light: 'light',
},
}));
describe('GlassButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders children text correctly', () => {
const { getByText } = render(
<GlassButton>Click Me</GlassButton>
);
expect(getByText('Click Me')).toBeTruthy();
});
it('renders with primary variant by default', () => {
const { getByText } = render(
<GlassButton>Primary Button</GlassButton>
);
const text = getByText('Primary Button');
expect(text).toBeTruthy();
});
it('renders secondary variant', () => {
const { getByText } = render(
<GlassButton variant="secondary">Secondary Button</GlassButton>
);
expect(getByText('Secondary Button')).toBeTruthy();
});
it('renders ghost variant', () => {
const { getByText } = render(
<GlassButton variant="ghost">Ghost Button</GlassButton>
);
expect(getByText('Ghost Button')).toBeTruthy();
});
it('renders danger variant', () => {
const { getByText } = render(
<GlassButton variant="danger">Danger Button</GlassButton>
);
expect(getByText('Danger Button')).toBeTruthy();
});
it('renders with icon when provided', () => {
const TestIcon = () => <Text testID="test-icon">Icon</Text>;
const { getByTestId, getByText } = render(
<GlassButton icon={<TestIcon />}>With Icon</GlassButton>
);
expect(getByTestId('test-icon')).toBeTruthy();
expect(getByText('With Icon')).toBeTruthy();
});
it('renders loading indicator when loading is true', () => {
const { queryByText, UNSAFE_getByType } = render(
<GlassButton loading>Loading Button</GlassButton>
);
// When loading, text should not be visible
expect(queryByText('Loading Button')).toBeNull();
// Activity indicator should be present
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('renders with different sizes', () => {
const { rerender, getByText } = render(
<GlassButton size="sm">Small</GlassButton>
);
expect(getByText('Small')).toBeTruthy();
rerender(<GlassButton size="md">Medium</GlassButton>);
expect(getByText('Medium')).toBeTruthy();
rerender(<GlassButton size="lg">Large</GlassButton>);
expect(getByText('Large')).toBeTruthy();
});
});
describe('press handling', () => {
it('calls onPress when pressed', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>Press Me</GlassButton>
);
fireEvent.press(getByText('Press Me'));
await waitFor(() => {
expect(onPress).toHaveBeenCalledTimes(1);
});
});
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} disabled>
Disabled Button
</GlassButton>
);
fireEvent.press(getByText('Disabled Button'));
expect(onPress).not.toHaveBeenCalled();
});
it('does not call onPress when loading', () => {
const onPress = jest.fn();
const { UNSAFE_getByType } = render(
<GlassButton onPress={onPress} loading>
Loading Button
</GlassButton>
);
const ActivityIndicator = require('react-native').ActivityIndicator;
const indicator = UNSAFE_getByType(ActivityIndicator);
// Try to press the parent pressable
// Since loading hides the text, we can't press by text
// The component should still be non-pressable
expect(onPress).not.toHaveBeenCalled();
});
// Note: Haptic tests use haptic={false} because the component uses dynamic import
// which doesn't work properly in Jest without --experimental-vm-modules
it('handles button with haptic prop', () => {
// Verify the component renders with haptic prop
// Actual haptic functionality cannot be tested due to dynamic import limitations
const { getByText } = render(
<GlassButton haptic>Haptic Button</GlassButton>
);
expect(getByText('Haptic Button')).toBeTruthy();
});
it('calls onPress when haptic is false', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>
No Haptic
</GlassButton>
);
fireEvent.press(getByText('No Haptic'));
await waitFor(() => {
expect(onPress).toHaveBeenCalled();
});
});
});
describe('styling', () => {
it('applies fullWidth style when fullWidth is true', () => {
const { getByText } = render(
<GlassButton fullWidth>Full Width Button</GlassButton>
);
const button = getByText('Full Width Button');
expect(button).toBeTruthy();
// The fullWidth style is applied to parent, which sets width: '100%'
});
it('applies custom style prop', () => {
const customStyle = { marginTop: 20 };
const { getByText } = render(
<GlassButton style={customStyle}>Styled Button</GlassButton>
);
expect(getByText('Styled Button')).toBeTruthy();
});
it('applies custom textStyle prop', () => {
const customTextStyle = { fontWeight: 'bold' as const };
const { getByText } = render(
<GlassButton textStyle={customTextStyle}>Styled Text</GlassButton>
);
expect(getByText('Styled Text')).toBeTruthy();
});
it('applies disabled opacity when disabled', () => {
const { getByText } = render(
<GlassButton disabled>Disabled Button</GlassButton>
);
// The disabled style reduces opacity to 0.5
expect(getByText('Disabled Button')).toBeTruthy();
});
});
describe('press in/out animations', () => {
it('handles press in event', () => {
const { getByText } = render(
<GlassButton>Animated Button</GlassButton>
);
const button = getByText('Animated Button');
// Trigger press in - this should work without errors
fireEvent(button, 'pressIn');
expect(button).toBeTruthy();
});
it('handles press out event', () => {
const { getByText } = render(
<GlassButton>Animated Button</GlassButton>
);
const button = getByText('Animated Button');
// Trigger press in and out
fireEvent(button, 'pressIn');
fireEvent(button, 'pressOut');
expect(button).toBeTruthy();
});
});
describe('default props', () => {
it('has default variant of primary', () => {
const { getByText } = render(<GlassButton>Default</GlassButton>);
expect(getByText('Default')).toBeTruthy();
});
it('has default size of md', () => {
const { getByText } = render(<GlassButton>Default Size</GlassButton>);
expect(getByText('Default Size')).toBeTruthy();
});
it('has haptic enabled by default', () => {
// The component has haptic={true} by default
// We can't test the actual haptic call due to dynamic import limitations
// but we verify the prop default works
const { getByText } = render(<GlassButton>Default Haptic</GlassButton>);
expect(getByText('Default Haptic')).toBeTruthy();
});
it('has disabled false by default', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>Not Disabled</GlassButton>
);
fireEvent.press(getByText('Not Disabled'));
await waitFor(() => {
expect(onPress).toHaveBeenCalled();
});
});
it('has loading false by default', () => {
const { getByText } = render(
<GlassButton>Not Loading</GlassButton>
);
expect(getByText('Not Loading')).toBeTruthy();
});
it('has fullWidth false by default', () => {
const { getByText } = render(<GlassButton>Not Full Width</GlassButton>);
expect(getByText('Not Full Width')).toBeTruthy();
});
});
});
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more