+
+<%# ❌ BAD: Inline SVG code %>
+
+
+<%# ✅ GOOD: inline_svg gem %>
+<%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-600" %>
+```
+
+## Icons with inline_svg
+
+**STRICT RULE:** Never write inline SVG code directly in ERB files.
+
+Always use the `inline_svg` gem:
+
+```erb
+<%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-600" %>
+<%= inline_svg "icons/chat.svg", class: "w-5 h-5 text-gray-400" %>
+```
+
+**Icon organization:**
+- Store icons in `app/assets/images/icons/`
+- Use semantic names: `users.svg`, `chat.svg`, `settings.svg`
+- Keep SVG files clean (viewBox, paths only)
+- Icons inherit color via `currentColor`
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..d9ec398
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,5 @@
+{
+ "enabledPlugins": {
+ "code-simplifier@claude-plugins-official": true
+ }
+}
diff --git a/.claude/skills/og-image/SKILL.md b/.claude/skills/og-image/SKILL.md
index d62b31c..32f1ddc 100644
--- a/.claude/skills/og-image/SKILL.md
+++ b/.claude/skills/og-image/SKILL.md
@@ -1,200 +1,154 @@
---
name: og-image
-description: Generate social media preview images (Open Graph) and configure meta tags. Creates a screenshot-optimized page using the project's existing design system, captures it at 1200x630, and sets up all social sharing meta tags.
+description: Generate social media preview images (Open Graph) for Rails apps. Creates an OG image using the project's design system at /og-image, screenshots it at 1200x630, and configures meta tags in the layout head.
---
-This skill creates professional Open Graph images for social media sharing. It analyzes the existing codebase to match the project's design system, generates a dedicated OG image page, screenshots it, and configures all necessary meta tags.
+This skill creates Open Graph images for social media sharing in Rails apps. It generates a dedicated page at `/og-image` matching the project's design system, then screenshots it for use in meta tags.
-## Workflow
-
-### Phase 1: Codebase Analysis
-
-Explore the project to understand:
-
-1. **Framework Detection**
- - Check `package.json` for Next.js, Vite, Astro, Remix, etc.
- - Identify the routing pattern (file-based, config-based)
- - Find where to create the `/og-image` route
-
-2. **Design System Discovery**
- - Look for Tailwind config (`tailwind.config.js/ts`) for color palette
- - Check for CSS variables in global styles (`:root` definitions)
- - Find existing color tokens, font families, spacing scales
- - Look for a theme or design tokens file
-
-3. **Branding Assets**
- - Find logo files in `/public`, `/assets`, `/src/assets`
- - Check for favicon, app icons
- - Look for existing hero sections or landing pages with branding
-
-4. **Product Information**
- - Extract product name from `package.json`, landing page, or meta tags
- - Find tagline/description from existing pages
- - Look for existing OG/meta configuration to understand current setup
-
-5. **Existing Components**
- - Find reusable UI components that could be leveraged
- - Check for glass effects, gradients, or distinctive visual patterns
- - Identify the overall aesthetic (dark mode, light mode, etc.)
-
-### Phase 2: OG Image Page Creation
-
-Create a dedicated route at `/og-image` (or equivalent for the framework):
-
-**Page Requirements:**
-- Fixed dimensions: exactly 1200px wide × 630px tall
-- Self-contained styling (no external dependencies that might not render)
-- Hide any dev tool indicators with CSS:
-```css
-[data-nextjs-dialog-overlay],
-[data-nextjs-dialog],
-nextjs-portal,
-#__next-build-indicator {
- display: none !important;
-}
-```
+## Architecture
-**Content Structure:**
-- Product logo/icon (prominent placement)
-- Product name with distinctive typography
-- Tagline or value proposition
-- Visual representation of the product (mockup, illustration, or abstract design)
-- URL/domain at the bottom
-- Background that matches the project aesthetic (gradients, patterns, etc.)
+The OG system has three layers:
-**Design Principles:**
-- Use the project's existing color palette
-- Match the typography from the main site
-- Include visual elements that represent the product
-- Ensure high contrast for readability at small sizes (social previews are often small)
-- Test that text is readable when the image is scaled down to ~400px wide
+1. **Helpers** in `ApplicationHelper` — `og_title`, `og_description`, `og_image`
+2. **Meta tags** in `app/views/layouts/application.html.erb` — call the helpers
+3. **Per-page overrides** — any view sets `content_for` to customize
-### Phase 3: Screenshot Capture
+```
+Layout meta tags
+ └─ og_title → content_for(:og_title) → content_for(:title) → t("app_name")
+ └─ og_description → content_for(:og_description) → t("og_image.description")
+ └─ og_image → content_for(:og_image) → request.base_url + "/og-image.png"
+```
-Use Playwright to capture the OG image:
+## Existing Files
-1. Navigate to the OG image page (typically `http://localhost:3000/og-image` or similar)
-2. Resize viewport to exactly 1200×630
-3. Wait for any animations to complete or fonts to load
-4. Take a PNG screenshot
-5. Save to the project's public folder as `og-image.png`
+These files already exist and should be edited, not recreated:
-**Playwright Commands:**
-```
-browser_navigate: http://localhost:{port}/og-image
-browser_resize: width=1200, height=630
-browser_take_screenshot: og-image.png (then copy to /public)
-```
+| File | Purpose |
+|------|---------|
+| `app/helpers/application_helper.rb` | `og_title`, `og_description`, `og_image` helpers |
+| `app/views/layouts/application.html.erb` | OG + Twitter meta tags in `` |
+| `app/controllers/og_images_controller.rb` | Renders the screenshot page |
+| `app/views/og_images/show.html.erb` | 1200x630 self-contained HTML page |
+| `config/routes.rb` | `GET /og-image` route |
+| `config/locales/en.yml` | `og_image.tagline` and `og_image.description` |
+| `lib/tasks/og_image.rake` | `rake og_image:generate` and `rake og_image:instructions` |
-### Phase 4: Meta Tag Configuration
-
-Audit and update the project's meta tag configuration. For Next.js App Router, update `layout.tsx`. For other frameworks, update the appropriate location.
-
-**Required Meta Tags:**
-
-```typescript
-// Open Graph
-openGraph: {
- title: "Product Name - Short Description",
- description: "Compelling description for social sharing",
- url: "https://yourdomain.com",
- siteName: "Product Name",
- locale: "en_US",
- type: "website",
- images: [{
- url: "/og-image.png", // or absolute URL
- width: 1200,
- height: 630,
- alt: "Descriptive alt text for accessibility",
- type: "image/png",
- }],
-},
-
-// Twitter/X
-twitter: {
- card: "summary_large_image",
- title: "Product Name - Short Description",
- description: "Compelling description for Twitter",
- creator: "@handle", // if provided
- images: [{
- url: "/og-image.png",
- width: 1200,
- height: 630,
- alt: "Descriptive alt text",
- }],
-},
-
-// Additional
-other: {
- "theme-color": "#000000", // match brand color
- "msapplication-TileColor": "#000000",
-},
-
-appleWebApp: {
- title: "Product Name",
- statusBarStyle: "black-translucent",
- capable: true,
-},
-```
+## Workflow
-**Ensure `metadataBase` is set** for relative URLs to resolve correctly:
-```typescript
-metadataBase: new URL("https://yourdomain.com"),
+### Phase 1: Understand the Design System
+
+Read these files to match the project aesthetic:
+
+- `app/assets/tailwind/application.css` — OKLCH color palette, fonts, custom utilities
+- `app/views/layouts/application.html.erb` — existing meta tags and structure
+- `app/assets/images/icons/` — available SVG icons for the image
+- `config/locales/en.yml` — app name, tagline, description
+
+### Phase 2: Update the OG Image Page
+
+Edit `app/views/og_images/show.html.erb`. This is a self-contained page with `layout false`.
+
+**Requirements:**
+- Exactly 1200px wide × 630px tall
+- Uses the project's Tailwind stylesheet
+- All custom colors must use OKLCH (project rule)
+- Uses `inline_svg` for icons (project rule — never inline SVG)
+- No authentication required
+
+**Template structure:**
+
+```erb
+
+
+
+
+
OG Image
+ <%%= stylesheet_link_tag "tailwind" %>
+
+
+
+
+ <%%= inline_svg "icons/lightning.svg", class: "w-14 h-14 text-white" %>
+
<%%= @app_name %>
+
<%%= @tagline %>
+
<%%= @domain %>
+
+
+
```
-### Phase 5: Verification & Output
+**Controller provides:**
+- `@app_name` — from `t("app_name")`
+- `@tagline` — from `t("og_image.tagline")`
+- `@domain` — from `request.host`
-1. **Verify the image exists** at the public path
-2. **Check meta tags** are correctly rendered in the HTML
-3. **Provide cache-busting instructions:**
- - Facebook/LinkedIn: https://developers.facebook.com/tools/debug/
- - Twitter/X: https://cards-dev.twitter.com/validator
- - LinkedIn: https://www.linkedin.com/post-inspector/
+### Phase 3: Screenshot
-4. **Summary output:**
- - Path to generated OG image
- - URL to preview the OG image page locally
- - List of meta tags added/updated
- - Links to social preview debuggers
+Tell the user to generate the static image:
-## Prompting for Missing Information
-
-Only ask the user if these cannot be determined from the codebase:
+```
+1. Start server: bin/dev
+2. Open: http://localhost:3000/og-image
+3. DevTools (F12) → device toolbar → 1200 x 630
+4. Right-click → "Capture screenshot"
+5. Save as: public/og-image.png
+```
-1. **Domain/URL** - If not found in existing config, ask: "What's your production domain? (e.g., https://example.com)"
+Or with Playwright: `rake og_image:generate`
-2. **Twitter/X handle** - If adding twitter:creator, ask: "What's your Twitter/X handle for attribution? (optional)"
+### Phase 4: Per-Page OG Images
-3. **Tagline** - If no clear tagline found, ask: "What's a short tagline for social previews? (1 sentence)"
+Any view can override defaults using `content_for`:
-## Framework-Specific Notes
+```erb
+<%% content_for :og_title, @post.title %>
+<%% content_for :og_description, @post.excerpt %>
+<%% content_for :og_image, "/og-images/posts/#{@post.id}.png" %>
+```
-**Next.js App Router:**
-- Create `/app/og-image/page.tsx`
-- Update metadata in `/app/layout.tsx`
-- Use `'use client'` directive for the OG page
+The helpers in `ApplicationHelper` cascade:
+
+```ruby
+def og_title
+ content_for(:og_title).presence || content_for(:title).presence || t("app_name")
+end
+
+def og_description
+ content_for(:og_description).presence || t("og_image.description")
+end
+
+def og_image
+ if content_for?(:og_image)
+ src = content_for(:og_image)
+ src.start_with?("http") ? src : "#{request.base_url}#{src}"
+ else
+ "#{request.base_url}/og-image.png"
+ end
+end
+```
-**Next.js Pages Router:**
-- Create `/pages/og-image.tsx`
-- Update `_app.tsx` or use `next-seo`
+### Phase 5: Verify
-**Vite/React:**
-- Create route via router config
-- Update `index.html` meta tags or use `react-helmet`
+- [ ] `/og-image` renders at 1200×630
+- [ ] `public/og-image.png` exists
+- [ ] Meta tags render in page source (`og:image`, `twitter:image`)
+- [ ] `og:image` URL is absolute (includes protocol + domain)
+- [ ] Per-page overrides work via `content_for`
+- [ ] `rails test` passes
+- [ ] `bundle exec rubocop -A` passes
-**Astro:**
-- Create `/src/pages/og-image.astro`
-- Update layout with meta tags
+Social preview debuggers:
+- Facebook: https://developers.facebook.com/tools/debug/
+- Twitter: https://cards-dev.twitter.com/validator
+- LinkedIn: https://www.linkedin.com/post-inspector/
-## Quality Checklist
+## Key Rules
-Before completing, verify:
-- [ ] OG image renders correctly at 1200×630
-- [ ] No dev tool indicators visible in screenshot
-- [ ] Image saved to public folder
-- [ ] Meta tags include og:image with absolute URL capability
-- [ ] Meta tags include twitter:card as summary_large_image
-- [ ] Meta tags include dimensions (width/height)
-- [ ] Meta tags include alt text for accessibility
-- [ ] theme-color is set to match brand
-- [ ] User informed of cache-busting URLs
+- **No env vars** — domain comes from `request.base_url` (configured via `bin/configure`)
+- **No hardcoded strings** — app name and tagline come from i18n (`config/locales/en.yml`)
+- **OKLCH colors only** — no hex/rgb/hsl in custom CSS
+- **`inline_svg` for icons** — never paste raw SVG into templates
+- **Minitest + fixtures** — for any new tests
diff --git a/.cursor/rules/clean-code.mdc b/.cursor/rules/clean-code.mdc
deleted file mode 100644
index 692cdbd..0000000
--- a/.cursor/rules/clean-code.mdc
+++ /dev/null
@@ -1,46 +0,0 @@
-
-## Constants Over Magic Numbers
-- Replace hard-coded values with named constants.
-- Use descriptive constant names that explain the value's purpose.
-- Keep constants at the top of the file or in a dedicated constants file.
-
-## Meaningful Names
-- Variables, functions, and classes should reveal their purpose.
-- Names should explain why something exists and how it's used.
-- Avoid abbreviations unless they're universally understood.
-
-## Smart Comments
-- Don't comment on what the code does — make the code self-documenting.
-- Use comments to explain *why* something is done a certain way, not *what* is done.
-- Document APIs, complex algorithms, and non-obvious side effects.
-
-## Single Responsibility
-- Each function should do exactly one thing.
-- Functions should be small and focused.
-- If a function needs a comment to explain what it does, it should be split.
-
-## DRY (Don't Repeat Yourself)
-- Extract repeated code into reusable functions.
-- Share common logic through proper abstraction.
-- Maintain single sources of truth.
-
-## Clean Structure
-- Keep related code together.
-- Organize code in a logical hierarchy.
-- Use consistent file and folder naming conventions.
-
-## Encapsulation
-- Hide implementation details.
-- Expose clear interfaces.
-- Move nested conditionals into well-named functions.
-
-## Code Quality Maintenance
-- Refactor continuously.
-- Fix technical debt early, before it accumulates.
-- **Follow the Boy Scout Principle: always leave the code cleaner than you found it.**
-- Treat small improvements as part of daily work, not as separate refactoring phases.
-
-## Testing
-- Write tests before fixing bugs.
-- Keep tests readable and maintainable.
-- Test edge cases and error conditions.
diff --git a/.cursor/rules/code-quality.mdc b/.cursor/rules/code-quality.mdc
deleted file mode 100644
index 1a484e9..0000000
--- a/.cursor/rules/code-quality.mdc
+++ /dev/null
@@ -1,44 +0,0 @@
-
-## Verify Information
-Always verify information before presenting it. Do not make assumptions or speculate without clear evidence.
-
-## File-by-File Changes
-Make changes file by file and give me a chance to spot mistakes.
-
-## Reasonably Sized Changes
-If a request the user makes can end up affecting many (more than 5) files, always propose changes gradually.
-That means you will implement the changes on a select number of files, then ask the user to confirm this is his intent, and only then proceed with the rest of the changes.
-
-## No Apologies
-Never use apologies.
-
-## No Understanding Feedback
-Avoid giving feedback about understanding in comments or documentation.
-
-## No Whitespace Suggestions
-Don't suggest whitespace changes.
-
-## Always Summarize
-Always summarize changes made.
-
-## No Inventions
-- Don't invent changes other than what's explicitly requested, except for where the "boy scout" principle applies, which is improving *related* code.
-- Do not implement documentation files, example files, test files or other additions, without explicitly asking if the user wants them.
-
-## No Unnecessary Confirmations
-Don't ask for confirmation of information already provided in the context.
-
-## Preserve Existing Code
-Don't remove unrelated code or functionalities. Pay attention to preserving existing structures.
-
-## No Implementation Checks
-Don't ask the user to verify implementations that are visible in the provided context.
-
-## No Unnecessary Updates
-Don't suggest updates or changes to files when there are no actual modifications needed.
-
-## Provide Real File Links
-Always provide links to the real files, not x.md.
-
-## No Current Implementation
-Don't show or discuss the current implementation unless specifically requested.
diff --git a/.cursor/rules/rails.mdc b/.cursor/rules/rails.mdc
deleted file mode 100644
index bc1df44..0000000
--- a/.cursor/rules/rails.mdc
+++ /dev/null
@@ -1,184 +0,0 @@
----
-description: Rails 8 specific rules and guidelines for the Social Script project. These rules complement the main .cursorrules file with detailed Rails-specific practices.
-globs: ["*.rb", "*.erb", "*.rake", "Gemfile", "Rakefile", "config/**/*.yml", "config/**/*.rb", "db/migrate/*.rb", "app/**/*"]
----
-
-# Your rule content
-
-- You can @ files here
-- You can use markdown but dont have to
-
-# Rails 8 Development Guidelines
-
-## 1. Rails 8 Core Features
-
-** Prefer the command line utilities to manually generated code **
-
-e.g use `rails generate model` instead of creating a model from scratch
-
-** IMPORTANT: Server Management **
-- Always use `bin/dev` to start the server (uses Procfile.dev)
-- Check logs after every significant change
-- Monitor development.log for errors and performance issues
-- Use `tail -f log/development.log` for real-time monitoring
-- Review logs before considering any change complete
-- Use modern Ruby 3.4 and Rails 8.1 syntax
-- Avoid using deprecated patterns, like OpenStruct
-
-1. **Modern Infrastructure**
- - Use Thruster for asset compression and caching
- - Implement Kamal 2 for deployment orchestration
- - Utilize Solid Queue for background job processing
- - Leverage Solid Cache for caching
- - Use Solid Cable for real-time features
- - Configure healthcheck silencing in production logs
-
-2. **Database Best Practices**
- - Use ULID as as the primary key:
- ```
- create_table :table, force: true, id: false do |t|
- t.primary_key :id, :string, default: -> { "ULID()" }
- ...
- end
- ```
- - Use SQLite full-text search capabilities
- - Configure proper database extensions in database.yml
- - Implement database partitioning for large datasets
- - Use proper database indexing strategies
- - Configure connection pooling
- - Implement proper backup strategies
- - Use SQLite-specific features
- - Monitor and optimize query performance
-
-3. **Controller Patterns**
- - Use `params.expect()` for safer parameter handling
- - Implement rate limiting via cache store
- - Use the new sessions generator for authentication
- - Silence healthcheck requests in production
- - Keep controllers RESTful and focused
- - Use service objects for complex business logic
-
-4. **Progressive Web App Features**
- - Utilize default PWA manifest
- - Implement service worker functionality
- - Configure browser version requirements
- - Use `allow_browser` to set minimum versions
- - Implement offline capabilities
- - Configure proper caching strategies
-
-## 2. Development Standards
-
-1. **Code Organization**
- - Follow Single Responsibility Principle
- - Use service objects for complex business logic
- - Keep controllers skinny
- - Use concerns for shared functionality
- - Use `params.expect()` instead of strong parameters
- - Follow Rails 8 conventions
-
-2. **Performance**
- - Use Thruster for asset compression
- - Implement proper caching with Solid Cache
- - Configure connection pooling
- - Use Solid Queue for background jobs
- - Monitor application metrics
- - Regular performance profiling
- - Optimize database queries
- - Use proper indexing strategies
-
-3. **Testing**
- - Write comprehensive Minitest tests
- - Use factories instead of fixtures
- - Test happy and edge cases
- - Keep tests DRY but readable
- - Use parallel testing by default
- - Regular security testing
- - Performance testing
- - Load testing for critical paths
-
-4. **Security**
- - Use `params.expect()` for parameter handling
- - Implement proper authorization
- - Sanitize user input
- - Follow OWASP guidelines
- - Configure rate limiting via cache store
- - Regular security audits
- - Keep dependencies updated
- - Use secure communication (HTTPS)
-
-5. **Hotwire Patterns**
- - Use Turbo Frames for partial page updates
- - Use Turbo Streams for real-time updates
- - Keep Stimulus controllers focused and simple
- - Use data attributes for JavaScript hooks
- - Use Solid Cable for real-time features
-
-6. **Deployment**
- - Use Kamal 2 for deployment orchestration
- - Configure healthcheck silencing
- - Use Propshaft for asset pipeline
- - Implement PWA features by default
- - Use devcontainer for development
- - Implement blue-green deployments
- - Configure proper health checks
- - Set up monitoring and alerts
-
-7. **Logging and Monitoring**
- - Check logs after every code change
- - Monitor development.log for errors
- - Use `tail -f log/development.log` for real-time monitoring
- - Review logs before marking tasks as complete
- - Set up proper log rotation
- - Configure log levels appropriately
- - Monitor performance metrics
- - Track error rates and patterns
-
-## 3. Directory Structure
-
-```
-/app
-├── components/ # View components
-│ └── ui/ # UI components
-├── controllers/ # Controllers
-├── models/ # Active Record models
-├── views/ # View templates
-├── helpers/ # View helpers
-├── javascript/ # Stimulus controllers
-│ └── controllers/
-├── services/ # Service objects
-├── policies/ # Pundit policies
-├── jobs/ # Background jobs
-├── mailers/ # Action Mailer classes
-└── assets/ # Assets (if not using importmap)
-```
-
-## 4. Tech Stack
-
-- **Backend**: Ruby on Rails 8
-- **Frontend**: Hotwire (Turbo + Stimulus)
-- **Styling**: Tailwind CSS
-- **Database**: SQLite
-- **Testing**: Minitest
-- **Background Jobs**: Solid Queue (default in Rails 8)
-- **Caching**: Solid Cache (default in Rails 8)
-- **Real-time**: Solid Cable
-- **Authentication**: Built-in Sessions Generator
-- **Authorization**: Pundit
-- **Deployment**: Kamal 2 (default in Rails 8)
-- **Asset Pipeline**: Propshaft (default in Rails 8)
-- **Container**: Docker (development & production)
-
-## 5. Rails-Specific Reminders
-
-1. Use `--skip-solid` if not using Solid Stack
-2. Configure healthcheck silencing in production
-3. Ensure Docker services are running before development
-4. Follow the new Rails 8 maintenance policy
-5. Keep dependencies updated
-6. Monitor application performance
-7. Regular security audits
-8. Use `params.expect()` instead of strong parameters
-9. Use Propshaft for asset pipeline
-10. Implement PWA features by default
-11. Always use `bin/dev` to start the server
-12. Check logs after every significant change
diff --git a/.gitignore b/.gitignore
index 13865f8..04962da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,9 +7,6 @@
# Ignore bundler config.
/.bundle
-# Ignore all environment files.
-/.env*
-
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
@@ -33,19 +30,19 @@
# Ignore master key for decrypting credentials and more.
/config/master.key
/config/credentials/*.key
+/config/*.key
+
+# Ignore configuration marker
+/.configured
/app/assets/builds/*
!/app/assets/builds/.keep
-# Ignoe macOS files
+# Ignore macOS files
.DS_Store
# Ignore Rubymine files
.idea/
-/config/credentials/development.key
-
-/config/credentials/production.key
-
-# MaxMind GeoLite2 database (download separately - not redistributable)
-/db/*.mmdb
+# MaxMind GeoLite2 database (not redistributable)
+db/*.mmdb
diff --git a/.kamal/secrets b/.kamal/secrets
index 6e61960..7d79fda 100644
--- a/.kamal/secrets
+++ b/.kamal/secrets
@@ -2,21 +2,13 @@
# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
-# Example of extracting secrets from 1password (or another compatible pw manager)
-# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
-# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS})
-# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})
-
-# Use a GITHUB_TOKEN if private repositories are needed for the image
-# GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
+# Rails master key for production credentials
+# This is the only secret needed - everything else comes from encrypted credentials
+RAILS_MASTER_KEY=$(cat config/credentials/production.key)
# Grab the registry password from Rails credentials
KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password)
-# Improve security by using a password manager. Never check config/master.key into git!
-RAILS_MASTER_KEY=$(cat config/credentials/production.key)
-
-# MaxMind credentials for GeoLite2 database (get from https://www.maxmind.com/en/accounts/current/license-key)
-# As of May 2024, MaxMind requires both account_id and license_key for downloads
-MAXMIND_ACCOUNT_ID=$(rails credentials:fetch --environment production maxmind.account_id 2>/dev/null || echo "")
-MAXMIND_LICENSE_KEY=$(rails credentials:fetch --environment production maxmind.license_key 2>/dev/null || echo "")
+# MaxMind credentials for GeoLite2 database download during Docker build (optional)
+MAXMIND_ACCOUNT_ID=$(RAILS_ENV=production bin/rails credentials:show 2>/dev/null | ruby -ryaml -e "puts YAML.safe_load(STDIN.read).dig('maxmind', 'account_id')" 2>/dev/null || echo "")
+MAXMIND_LICENSE_KEY=$(RAILS_ENV=production bin/rails credentials:show 2>/dev/null | ruby -ryaml -e "puts YAML.safe_load(STDIN.read).dig('maxmind', 'license_key')" 2>/dev/null || echo "")
diff --git a/AGENTS.md b/AGENTS.md
index 01833ff..4a2b5e6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,8 @@ rails generate migration MigrationName
## Architecture & Key Patterns
+The codebase follows **37signals vanilla Rails style**: fat models with concerns, thin controllers, no service objects. All domain logic lives in models and model concerns. There is no `app/services/` directory.
+
### Data Model Structure
The application uses a **Universal Content Model** where the `Post` model handles three distinct content types via the `post_type` enum:
@@ -84,21 +86,77 @@ The application uses a **Universal Content Model** where the `Post` model handle
- `Comment`: User feedback on posts with published flag
- `Report`: Content moderation by trusted users, auto-hides after 3+ reports
- `Testimonial`: User testimonials ("why I love Ruby") with AI-generated headline, subheadline, and quote. Validated by LLM for appropriateness
-- `Project`: GitHub repositories for users with star counts, language, description. Replaces the old `github_repos` JSON column
+- `Project`: GitHub repositories for users with star counts, language, description
- `StarSnapshot`: Daily star count snapshots per project, used to compute trending/stars gained
+- `Chat`: RubyLLM chat records (`acts_as_chat`). Has a `purpose` column: `"conversation"` (default), `"summary"`, `"testimonial_generation"`, `"testimonial_validation"`. System chats (non-conversation) track AI operations for cost accounting
+- `Model`: RubyLLM model records (`acts_as_model`). Tracks available AI models and their cumulative costs
+
+### Concern Catalog
+
+All domain logic that was previously in service objects now lives in model concerns, following the 37signals naming convention (adjective-named, namespaced to the model they belong to).
+
+**User concerns** (`app/models/concerns/user/`):
+- `User::Geocodable` — geocodes free-text locations into structured data (city, country, coordinates) via Photon API, resolves timezone from coordinates
+- `User::GithubSyncable` — syncs GitHub profile data and repositories via GraphQL API on sign-in, supports batch fetching for bulk updates
+
+**Post concerns** (`app/models/concerns/post/`):
+- `Post::SvgSanitizable` — sanitizes SVG content to prevent XSS attacks in success story logos
+- `Post::MetadataFetchable` — fetches OpenGraph metadata and external content from URLs for link posts
+- `Post::ImageVariantable` — processes featured images into WebP variants (small, medium, large, og) via ActiveStorage
+- `Post::OgImageGeneratable` — generates OG images for success stories from SVG logos
+- `Post::AiSummarizable` — generates AI summary teasers via RubyLLM, creates a system chat with `purpose: "summary"`
+
+**Testimonial concerns** (`app/models/concerns/testimonial/`):
+- `Testimonial::AiGeneratable` — AI-generates headline, subheadline, and body text from user testimonial quote via RubyLLM
+- `Testimonial::AiValidatable` — validates testimonial content for appropriateness via RubyLLM
+
+**Shared concerns** (`app/models/concerns/`):
+- `Costable` — cost formatting and calculation for models with a `total_cost` column (used by User, Chat, Model)
+
+### AI Operations (RubyLLM)
+
+All AI operations use the **RubyLLM** gem (~> 1.9), configured with `default_model: "gpt-4.1-nano"` in `config/initializers/ruby_llm.rb`.
+
+**How AI operations work:**
+1. A concern method (e.g., `Post#generate_summary!`) creates a system `Chat` record with a specific `purpose`
+2. It calls `chat.ask(prompt)` which uses RubyLLM to send the request and record the response as messages
+3. Message costs are tracked automatically via RubyLLM's `acts_as_chat` / `acts_as_model`
+4. Per-user spending is available via the `Costable` concern on `User`
+
+**Chat purposes:**
+- `"conversation"` — default, for user-facing chats (not currently used in this app)
+- `"summary"` — AI summary generation for posts
+- `"testimonial_generation"` — AI headline/subheadline/body generation for testimonials
+- `"testimonial_validation"` — AI content validation for testimonials
+
+**Key pattern:** Jobs are thin delegators that call model methods. The model concern owns all the logic:
+```ruby
+# Job (thin delegator)
+class GenerateSummaryJob < ApplicationJob
+ def perform(post, force: false)
+ post.generate_summary!(force: force)
+ end
+end
+
+# Concern (owns the logic)
+module Post::AiSummarizable
+ def generate_summary!(force: false)
+ chat = user.chats.create!(purpose: "summary", model: Model.find_by(...))
+ response = chat.ask(prompt)
+ update!(summary: clean_ai_summary(response.content))
+ end
+end
+```
### Primary Keys & IDs
-**All tables use UUIDv7 string primary keys** (migrated from ULID):
+**All tables use UUIDv7 string primary keys** via the `uuid7()` SQLite function:
```ruby
-create_table :table_name, id: false do |t|
- t.primary_key :id, :string, default: -> { "uuid_generate_v7()" }
+create_table :table_name, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t|
# ...
end
```
-UUIDv7 provides time-ordered, universally unique IDs without requiring the `sqlite-ulid` extension.
-
### Authentication & Authorization
- **Authentication**: GitHub OAuth only via Devise + OmniAuth
@@ -117,18 +175,20 @@ UUIDv7 provides time-ordered, universally unique IDs without requiring the `sqli
### Background Jobs (SolidQueue)
-- `GenerateSummaryJob`: Creates AI summaries for new/updated posts using OpenAI or Anthropic APIs
-- `GenerateSuccessStoryImageJob`: Generates OG images for success stories from SVG logos
-- `GenerateTestimonialFieldsJob`: AI-generates headline, subheadline, and quote from user testimonial text
-- `ValidateTestimonialJob`: LLM-validates testimonial content for appropriateness
+All jobs are thin delegators that call model methods. The logic lives in model concerns, not in jobs.
+
+- `GenerateSummaryJob`: Calls `post.generate_summary!` (AI summary via `Post::AiSummarizable`)
+- `GenerateSuccessStoryImageJob`: Calls `post.generate_og_image!` (OG image via `Post::OgImageGeneratable`)
+- `GenerateTestimonialJob`: Calls `testimonial.generate_ai_fields!` (AI fields via `Testimonial::AiGeneratable`)
+- `ValidateTestimonialJob`: Calls `testimonial.validate_with_ai!` (AI validation via `Testimonial::AiValidatable`)
- `NotifyAdminJob`: Sends notifications when content is auto-hidden
-- `UpdateGithubDataJob`: Refreshes user GitHub data via GraphQL API (repositories, stars, etc.)
-- `NormalizeLocationJob`: Geocodes user locations via OpenAI for map display
+- `UpdateGithubDataJob`: Batch-syncs GitHub data via `User.batch_sync_github_data!` (from `User::GithubSyncable`)
+- `NormalizeLocationJob`: Calls `user.geocode!` (geocoding via `User::Geocodable`)
- `ScheduledNewsletterJob`: Sends newsletter emails at timezone-appropriate times
### Image Processing
-Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing uses the `ImageProcessor` service.
+Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple WebP variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing is handled by the `Post::ImageVariantable` concern.
### URL Routing Pattern
@@ -155,17 +215,6 @@ Domain config lives in `config/initializers/domains.rb`. In development, communi
**Footer legal links**: Use `main_site_url(path)` helper to ensure legal page links resolve to the primary domain when viewed on the community domain.
-### Services Layer
-
-Service objects in `app/services/` handle complex operations:
-- `GithubDataFetcher`: Fetches and updates user GitHub profile data and repositories via GraphQL API on sign-in
-- `ImageProcessor`: Processes featured images into multiple variants (small, medium, large, og)
-- `SuccessStoryImageGenerator`: Generates OG images for success stories from SVG logos
-- `SvgSanitizer`: Sanitizes SVG content to prevent XSS attacks
-- `LocationNormalizer`: Geocodes free-text user locations into structured data (city, country, coordinates) using OpenAI
-- `TimezoneResolver`: Resolves timezone from coordinates, normalizes legacy timezone identifiers
-- `MetadataFetcher`: Fetches OpenGraph metadata from URLs for link posts
-
### FriendlyId Implementation
Both `User` and `Post` models use FriendlyId with history:
@@ -192,11 +241,10 @@ Both models implement `create_slug_history` to manually save old slugs when chan
### Migrations
-Always use UUIDv7 string primary keys. Never use auto-increment integers:
+Always use UUIDv7 string primary keys via the `uuid7()` SQLite function. Never use auto-increment integers:
```ruby
-create_table :posts, id: false do |t|
- t.primary_key :id, :string, default: -> { "uuid_generate_v7()" }
+create_table :posts, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t|
t.string :title, null: false
# ...
end
@@ -240,7 +288,7 @@ github:
client_secret: your_github_oauth_app_client_secret
openai:
- api_key: your_openai_api_key # Optional - for AI summaries
+ api_key: your_openai_api_key # Used by RubyLLM for AI operations
```
### GitHub OAuth Setup
diff --git a/Dockerfile b/Dockerfile
index 6729d77..7ad187d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,8 +2,8 @@
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
-# docker build -t template .
-# docker run -d -p 80:80 -e RAILS_MASTER_KEY=
--name template template
+# docker build -t why_ruby .
+# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name why_ruby why_ruby
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
@@ -16,29 +16,30 @@ WORKDIR /rails
RUN echo 'Acquire::http::Pipeline-Depth 0;\nAcquire::http::No-Cache true;\nAcquire::BrokenProxy true;\n' > /etc/apt/apt.conf.d/99fixbadproxy
-
-# Install base packages
+# Install base packages and set up jemalloc
RUN rm -rf /var/lib/apt/lists/* && \
apt-get update -qq --fix-missing && \
- apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 imagemagick librsvg2-bin && \
- rm -rf /var/lib/apt/lists/*
+ apt-get install --no-install-recommends -y curl libjemalloc2 libssl-dev libvips sqlite3 imagemagick librsvg2-bin && \
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
+ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/lib/libjemalloc.so.2
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
- BUNDLE_WITHOUT="development"
+ BUNDLE_WITHOUT="development:test" \
+ LD_PRELOAD="/usr/lib/libjemalloc.so.2"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y build-essential git pkg-config libyaml-dev zlib1g-dev && \
+ apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config zlib1g-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
-COPY Gemfile Gemfile.lock ./
+COPY Gemfile Gemfile.lock vendor ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
@@ -46,23 +47,24 @@ RUN bundle install && \
# Copy application code
COPY . .
-# Download MaxMind GeoLite2 database for IP geolocation (requires account ID and license key)
-# MaxMind API changed in May 2024 to require Basic Auth with account_id:license_key
+# Download MaxMind GeoLite2 database for IP geolocation (optional)
RUN --mount=type=secret,id=MAXMIND_ACCOUNT_ID \
--mount=type=secret,id=MAXMIND_LICENSE_KEY \
if [ -f /run/secrets/MAXMIND_ACCOUNT_ID ] && [ -f /run/secrets/MAXMIND_LICENSE_KEY ]; then \
ACCOUNT_ID="$(cat /run/secrets/MAXMIND_ACCOUNT_ID)" && \
LICENSE_KEY="$(cat /run/secrets/MAXMIND_LICENSE_KEY)" && \
- curl -sL -u "${ACCOUNT_ID}:${LICENSE_KEY}" \
- "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz" | \
- tar -xzf - --strip-components=1 -C db/ --wildcards "*/*.mmdb" && \
- echo "GeoLite2 database downloaded successfully"; \
+ curl -sfL -o /tmp/geolite2.tar.gz -u "${ACCOUNT_ID}:${LICENSE_KEY}" \
+ "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz" && \
+ tar -xzf /tmp/geolite2.tar.gz --strip-components=1 -C db/ --wildcards "*/*.mmdb" && \
+ rm -f /tmp/geolite2.tar.gz && \
+ echo "GeoLite2 database downloaded successfully" || \
+ echo "WARNING: GeoLite2 download failed, skipping (non-fatal)"; \
else \
echo "MAXMIND credentials not provided, skipping GeoLite2 download"; \
fi
-# Precompile bootsnap code for faster boot times
-RUN bundle exec bootsnap precompile app/ lib/
+# Precompile bootsnap code for faster boot times (use -j 1 for QEMU compatibility)
+RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production with RAILS_MASTER_KEY from secrets
RUN --mount=type=secret,id=RAILS_MASTER_KEY \
@@ -70,19 +72,20 @@ RUN --mount=type=secret,id=RAILS_MASTER_KEY \
./bin/rails assets:precompile
-
-
# Final stage for app image
FROM base
-# Copy built artifacts: gems, application
-COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
-COPY --from=build /rails /rails
+# OCI labels
+LABEL org.opencontainers.image.source="https://github.com/newstler/why_ruby"
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
- useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
- chown -R rails:rails db log storage tmp
+ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
+
+# Copy built artifacts: gems, application
+COPY --from=build --chown=rails:rails "${BUNDLE_PATH}" "${BUNDLE_PATH}"
+COPY --from=build --chown=rails:rails /rails /rails
+
USER 1000:1000
# Entrypoint prepares the database.
diff --git a/Gemfile b/Gemfile
index be06842..fa5238a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,7 +6,7 @@ gem "rails", github: "rails/rails", branch: "main"
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
-gem "sqlean", "~> 0.2"
+gem "sqlean", "~> 0.2" # SQLite extensions including uuid7()
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
@@ -37,31 +37,36 @@ gem "kamal", require: false
# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/]
gem "thruster", require: false
-# Authentication
-gem "devise", "~> 4.9"
+# Litestream for SQLite replication [https://github.com/fractaledmind/litestream-ruby]
+gem "litestream"
+
+# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
+gem "image_processing", "~> 1.2"
+
+# Authentication (GitHub OAuth only, no Devise)
gem "omniauth-github", "~> 2.0"
-gem "omniauth-rails_csrf_protection", "~> 1.0"
+gem "omniauth-rails_csrf_protection", "~> 2.0"
# Admin
-gem "avo", ">= 3.2"
+gem "madmin", "~> 2.1"
# Markdown and code syntax highlighting
-gem "redcarpet", "~> 3.6"
-gem "rouge", "~> 4.0"
+gem "redcarpet"
+gem "rouge"
-# AI integration for summaries
-gem "ruby-openai", "~> 8.2"
-gem "anthropic", "~> 1.6.0"
+# AI integration
+gem "ruby_llm", "~> 1.9"
# Pagination
gem "kaminari", "~> 1.2"
# IP Geolocation (for analytics country code)
-gem "geocoder", "~> 1.8"
-gem "maxminddb", "~> 0.1"
+gem "geocoder"
+gem "maxminddb"
# Friendly URLs
-gem "friendly_id", "~> 5.5"
+gem "friendly_id"
+gem "babosa"
# Timezone lookup from coordinates (offline, pure Ruby)
gem "wheretz"
@@ -69,6 +74,21 @@ gem "wheretz"
# HTML/XML parsing
gem "nokogiri", "~> 1.16"
+# Icons
+gem "inline_svg"
+
+# MCP: Model Context Protocol
+gem "fast-mcp", "~> 1.6"
+
+# Billing
+gem "stripe"
+
+# Multilingual content
+gem "mobility", "~> 1.3"
+
+# Monitor performance
+gem "rorvswild", "~> 1.9"
+
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
@@ -83,6 +103,9 @@ group :development, :test do
# Git hooks manager - automatically runs RuboCop before commits
gem "lefthook", require: false
+
+ # i18n tasks for managing translations [https://github.com/glebm/i18n-tasks]
+ gem "i18n-tasks", "~> 1.0"
end
group :development do
@@ -97,10 +120,4 @@ group :test do
gem "webmock"
end
-# Backup data to S3
-gem "litestream", "~> 0.14.0"
-
-# Monitor performance
-gem "rorvswild", "~> 1.9"
-
gem "tidewave", "~> 0.4.1", group: :development
diff --git a/Gemfile.lock b/Gemfile.lock
index 0dea60b..298bbd1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/rails/rails.git
- revision: 65e0e67906433b550377fd201a4d512f643e2b28
+ revision: a0da6b991a1c2cb1df2560a74ae3a86bc988f231
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -31,7 +31,7 @@ GIT
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
- rails-html-sanitizer (~> 1.6)
+ rails-html-sanitizer (~> 1.7)
useragent (~> 0.16)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.16)
@@ -46,7 +46,7 @@ GIT
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
- rails-html-sanitizer (~> 1.6)
+ rails-html-sanitizer (~> 1.7)
activejob (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
globalid (>= 0.3.6)
@@ -103,39 +103,17 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- action_text-trix (2.1.16)
+ action_text-trix (2.1.18)
railties
- active_link_to (1.0.5)
- actionpack
- addressable
- addressable (2.8.8)
+ addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
- anthropic (1.6.0)
- connection_pool
ast (2.4.3)
- avo (3.28.0)
- actionview (>= 6.1)
- active_link_to
- activerecord (>= 6.1)
- activesupport (>= 6.1)
- addressable
- avo-icons (>= 0.1.1)
- docile
- meta-tags
- pagy (>= 7.0.0, < 43)
- prop_initializer (>= 0.2.0)
- turbo-rails (>= 2.0.0)
- turbo_power (>= 0.6.0)
- view_component (>= 3.7.0)
- zeitwerk (>= 2.6.12)
- avo-icons (0.1.1)
- inline_svg
+ babosa (2.0.0)
base64 (0.3.0)
- bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.1)
bindex (0.8.1)
- bootsnap (1.21.1)
+ bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (8.0.4)
racc
@@ -160,13 +138,6 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
- devise (4.9.4)
- bcrypt (~> 3.0)
- orm_adapter (~> 0.1)
- railties (>= 4.1.0)
- responders
- warden (~> 1.2.3)
- docile (1.4.1)
dotenv (3.2.0)
drb (2.2.3)
dry-configurable (1.3.0)
@@ -183,15 +154,15 @@ GEM
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
- dry-schema (1.15.0)
+ dry-schema (1.16.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.1)
dry-initializer (~> 3.2)
dry-logic (~> 1.6)
- dry-types (~> 1.8)
+ dry-types (~> 1.9, >= 1.9.1)
zeitwerk (~> 2.6)
- dry-types (1.9.0)
+ dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
@@ -199,12 +170,12 @@ GEM
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
ed25519 (1.4.0)
- erb (6.0.1)
+ erb (6.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
event_stream_parser (1.0.0)
- faraday (2.14.0)
+ faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -212,6 +183,8 @@ GEM
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
+ faraday-retry (2.4.0)
+ faraday (~> 2.0)
fast-mcp (1.6.0)
addressable (~> 2.8)
base64
@@ -219,6 +192,10 @@ GEM
json (~> 2.0)
mime-types (~> 3.4)
rack (>= 2.0, < 4.0)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-musl)
friendly_id (5.6.0)
activerecord (>= 4.0.0)
fugit (1.12.1)
@@ -232,8 +209,25 @@ GEM
hashdiff (1.2.1)
hashie (5.1.0)
logger
+ highline (3.1.2)
+ reline
i18n (1.14.8)
concurrent-ruby (~> 1.0)
+ i18n-tasks (1.1.2)
+ activesupport (>= 4.0.2)
+ ast (>= 2.1.0)
+ erubi
+ highline (>= 3.0.0)
+ i18n
+ parser (>= 3.2.2.1)
+ prism
+ rails-i18n
+ rainbow (>= 2.2.2, < 4.0)
+ ruby-progressbar (~> 1.8, >= 1.8.1)
+ terminal-table (>= 1.5.1)
+ image_processing (1.14.0)
+ mini_magick (>= 4.9.5, < 6)
+ ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -242,17 +236,18 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.2)
- irb (1.16.0)
+ irb (1.17.0)
pp (>= 0.6.0)
+ prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
- json (2.18.0)
+ json (2.19.3)
jwt (3.1.2)
base64
- kamal (2.10.1)
+ kamal (2.11.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -276,7 +271,7 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
- lefthook (2.0.16)
+ lefthook (2.1.5)
lint_roller (1.1.0)
litestream (0.14.0-aarch64-linux)
actionpack (>= 7.0)
@@ -300,9 +295,16 @@ GEM
railties (>= 7.0)
sqlite3
logger (1.7.0)
- loofah (2.25.0)
+ loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
+ madmin (2.3.2)
+ importmap-rails
+ pagy (>= 3.5)
+ propshaft
+ rails (>= 7.0.0)
+ stimulus-rails
+ turbo-rails
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
@@ -312,22 +314,26 @@ GEM
marcel (1.1.0)
matrix (0.4.3)
maxminddb (0.1.22)
- meta-tags (2.22.3)
- actionpack (>= 6.0.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
- mime-types-data (3.2026.0127)
+ mime-types-data (3.2026.0331)
+ mini_magick (5.3.1)
+ logger
mini_mime (1.1.5)
- minitest (6.0.1)
+ minitest (6.0.3)
+ drb (~> 2.0)
prism (~> 1.5)
+ mobility (1.3.2)
+ i18n (>= 0.6.10, < 2)
+ request_store (~> 1.0)
msgpack (1.8.0)
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
net-http (0.9.1)
uri (>= 0.11.1)
- net-imap (0.6.2)
+ net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -340,15 +346,15 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
- net-ssh (7.3.0)
+ net-ssh (7.3.2)
nio4r (2.7.5)
- nokogiri (1.19.0-aarch64-linux-gnu)
+ nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.0-arm64-darwin)
+ nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.0-x86_64-linux-gnu)
+ nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.0-x86_64-linux-musl)
+ nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
oauth2 (2.0.18)
faraday (>= 0.17.3, < 4.0)
@@ -369,22 +375,22 @@ GEM
omniauth-oauth2 (1.9.0)
oauth2 (>= 2.0.2, < 3)
omniauth (~> 2.0)
- omniauth-rails_csrf_protection (1.0.2)
+ omniauth-rails_csrf_protection (2.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
- orm_adapter (0.5.0)
ostruct (0.6.3)
- pagy (9.4.0)
- parallel (1.27.0)
- parser (3.3.10.1)
+ pagy (43.4.4)
+ json
+ uri
+ yaml
+ parallel (1.28.0)
+ parser (3.3.11.1)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
- prop_initializer (0.2.0)
- zeitwerk (>= 2.6.18)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -392,12 +398,12 @@ GEM
psych (5.3.1)
date
stringio
- public_suffix (7.0.2)
+ public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.4)
+ rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
@@ -413,26 +419,28 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.6.2)
- loofah (~> 2.21)
+ rails-html-sanitizer (1.7.0)
+ loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ rails-i18n (8.1.0)
+ i18n (>= 0.7, < 2)
+ railties (>= 8.0.0, < 9)
rainbow (3.1.1)
rake (13.3.1)
- rdoc (7.1.0)
+ rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
redcarpet (3.6.1)
- regexp_parser (2.11.3)
+ regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
- responders (3.2.0)
- actionpack (>= 7.0)
- railties (>= 7.0)
+ request_store (1.7.0)
+ rack (>= 1.4)
rexml (3.4.4)
- rorvswild (1.10.1)
+ rorvswild (1.11.1)
rouge (4.7.0)
- rubocop (1.84.0)
+ rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -443,7 +451,7 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.49.0)
+ rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
@@ -460,14 +468,24 @@ GEM
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
- ruby-openai (8.3.0)
- event_stream_parser (>= 0.3.0, < 2.0.0)
- faraday (>= 1)
- faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
+ ruby-vips (2.3.0)
+ ffi (~> 1.12)
+ logger
+ ruby_llm (1.14.1)
+ base64
+ event_stream_parser (~> 1)
+ faraday (>= 1.10.0)
+ faraday-multipart (>= 1)
+ faraday-net_http (>= 1)
+ faraday-retry (>= 1)
+ marcel (~> 1)
+ ruby_llm-schema (~> 0)
+ zeitwerk (~> 2)
+ ruby_llm-schema (0.3.0)
rubyzip (3.2.2)
securerandom (0.4.1)
- selenium-webdriver (4.40.0)
+ selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -485,7 +503,7 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
- solid_queue (1.3.1)
+ solid_queue (1.4.0)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
@@ -496,10 +514,10 @@ GEM
sqlean (0.3.0-arm64-darwin)
sqlean (0.3.0-x86_64-linux-gnu)
sqlean (0.3.0-x86_64-linux-musl)
- sqlite3 (2.9.0-aarch64-linux-gnu)
- sqlite3 (2.9.0-arm64-darwin)
- sqlite3 (2.9.0-x86_64-linux-gnu)
- sqlite3 (2.9.0-x86_64-linux-musl)
+ sqlite3 (2.9.2-aarch64-linux-gnu)
+ sqlite3 (2.9.2-arm64-darwin)
+ sqlite3 (2.9.2-x86_64-linux-gnu)
+ sqlite3 (2.9.2-x86_64-linux-musl)
sshkit (1.25.0)
base64
logger
@@ -510,28 +528,31 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
+ stripe (19.0.0)
+ bigdecimal
+ logger
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
- tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
- tailwindcss-ruby (4.1.18-arm64-darwin)
- tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
- tailwindcss-ruby (4.1.18-x86_64-linux-musl)
+ tailwindcss-ruby (4.2.1-aarch64-linux-gnu)
+ tailwindcss-ruby (4.2.1-arm64-darwin)
+ tailwindcss-ruby (4.2.1-x86_64-linux-gnu)
+ tailwindcss-ruby (4.2.1-x86_64-linux-musl)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
thor (1.5.0)
- thruster (0.1.17-aarch64-linux)
- thruster (0.1.17-arm64-darwin)
- thruster (0.1.17-x86_64-linux)
- tidewave (0.4.1)
+ thruster (0.1.20-aarch64-linux)
+ thruster (0.1.20-arm64-darwin)
+ thruster (0.1.20-x86_64-linux)
+ tidewave (0.4.2)
fast-mcp (~> 1.6.0)
rack (>= 2.0)
rails (>= 7.1.0)
- timeout (0.6.0)
+ timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
- turbo_power (0.7.0)
- turbo-rails (>= 1.3.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -540,18 +561,11 @@ GEM
uri (1.1.1)
useragent (0.16.11)
version_gem (1.1.9)
- view_component (4.2.0)
- actionview (>= 7.1.0)
- activesupport (>= 7.1.0)
- concurrent-ruby (~> 1)
- warden (1.2.9)
- rack (>= 2.0.9)
- web-console (4.2.1)
- actionview (>= 6.0.0)
- activemodel (>= 6.0.0)
+ web-console (4.3.0)
+ actionview (>= 8.0.0)
bindex (>= 0.4.0)
- railties (>= 6.0.0)
- webmock (3.26.1)
+ railties (>= 8.0.0)
+ webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -563,7 +577,8 @@ GEM
wheretz (0.0.6)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.7.4)
+ yaml (0.4.0)
+ zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
@@ -574,34 +589,38 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
- anthropic (~> 1.6.0)
- avo (>= 3.2)
+ babosa
bootsnap
brakeman
capybara
debug
- devise (~> 4.9)
dotenv
- friendly_id (~> 5.5)
- geocoder (~> 1.8)
+ fast-mcp (~> 1.6)
+ friendly_id
+ geocoder
+ i18n-tasks (~> 1.0)
+ image_processing (~> 1.2)
importmap-rails
+ inline_svg
jbuilder
kamal
kaminari (~> 1.2)
lefthook
- litestream (~> 0.14.0)
- maxminddb (~> 0.1)
+ litestream
+ madmin (~> 2.1)
+ maxminddb
+ mobility (~> 1.3)
nokogiri (~> 1.16)
omniauth-github (~> 2.0)
- omniauth-rails_csrf_protection (~> 1.0)
+ omniauth-rails_csrf_protection (~> 2.0)
propshaft
puma (>= 5.0)
rails!
- redcarpet (~> 3.6)
+ redcarpet
rorvswild (~> 1.9)
- rouge (~> 4.0)
+ rouge
rubocop-rails-omakase
- ruby-openai (~> 8.2)
+ ruby_llm (~> 1.9)
selenium-webdriver
solid_cable
solid_cache
@@ -609,6 +628,7 @@ DEPENDENCIES
sqlean (~> 0.2)
sqlite3 (>= 2.1)
stimulus-rails
+ stripe
tailwindcss-rails (~> 4.0)
thruster
tidewave (~> 0.4.1)
@@ -618,30 +638,26 @@ DEPENDENCIES
wheretz
CHECKSUMS
- action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a
+ action_text-trix (2.1.18) sha256=3fdb83f8bff4145d098be283cdd47ac41caf5110bfa6df4695ed7127d7fb3642
actioncable (8.2.0.alpha)
actionmailbox (8.2.0.alpha)
actionmailer (8.2.0.alpha)
actionpack (8.2.0.alpha)
actiontext (8.2.0.alpha)
actionview (8.2.0.alpha)
- active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba
activejob (8.2.0.alpha)
activemodel (8.2.0.alpha)
activerecord (8.2.0.alpha)
activestorage (8.2.0.alpha)
activesupport (8.2.0.alpha)
- addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
- anthropic (1.6.0) sha256=61fa13d73f54d8174bf8b45cc058768e79e824ebc2aefc9417a2b94d9127ab75
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
- avo (3.28.0) sha256=9a7ab701f41ee201b87553a36f0d34d4fd03a7c7737aeba6c0da09ba5a031910
- avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c
+ babosa (2.0.0) sha256=a6218db8a4dc8fd99260dde8bc3d5fa1a0c52178196e236ebb31e41fbdcdb8a6
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
- bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
- bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
+ bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
- bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4
+ bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214
brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
@@ -652,8 +668,6 @@ CHECKSUMS
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
- devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8
- docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8
@@ -661,163 +675,174 @@ CHECKSUMS
dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3
dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2
- dry-schema (1.15.0) sha256=0f2a34adba4206bd6d46ec1b6b7691b402e198eecaff1d8349a7d48a77d82cd2
- dry-types (1.9.0) sha256=7b656fe0a78d2432500ae1f29fefd6762f5a032ca7000e4f36bc111453d45d4d
+ dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727
+ dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
- erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
+ erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8
- faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd
+ faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c
+ faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fast-mcp (1.6.0) sha256=d68abb45d2daab9e7ae2934417460e4bf9ac87493c585dc5bb626f1afb7d12c4
+ ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
+ ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
+ ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
+ ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
friendly_id (5.6.0) sha256=28e221cd53fbd21586321164c1c6fd0c9ba8dde13969cb2363679f44726bb0c3
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870
+ highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
+ i18n-tasks (1.1.2) sha256=4dcfba49e52a623f30661cb316cb80d84fbba5cb8c6d88ef5e02545fffa3637a
+ image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
- irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
+ irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42
- json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
+ json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535
- kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340
+ kamal (2.11.0) sha256=1408864425e0dec7e0a14d712a3b13f614e9f3a425b7661d3f9d287a51d7dd75
kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e
kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909
kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430
kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
- lefthook (2.0.16) sha256=c23ac3732ef9e7c6e9db4bc97bc4813d1a648db5d00e0f70cca426d04f3edf2e
+ lefthook (2.1.5) sha256=24bd602992e1ec36057bcc667bb74f23918ed05c715050a44e5a156f0b7a4312
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
litestream (0.14.0-aarch64-linux) sha256=bcf9199a665e673e27f929a0941011e50fb8ebf441d9754247686b514fba60d5
litestream (0.14.0-arm64-darwin) sha256=507bbb7ee99b3398304c5ef4a9bae835761359ffc72850f25708477805313d07
litestream (0.14.0-x86_64-linux) sha256=2844734b6d8e5c6009baf8d138d6f18367f770e9e4390fb70763433db587bed6
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
- loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
+ loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
+ madmin (2.3.2) sha256=6961cbaeed82634240c7c9888a49b181834bf9b85a9282caebf0ee7f368df73c
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
maxminddb (0.1.22) sha256=50933be438fbed9dceabef4163eab41884bd8830d171fdb8f739bee769c4907e
- meta-tags (2.22.3) sha256=41ead5437140869717cbdd659cc6f1caa3e498b3e74b03ed63503b5b38ed504f
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
- mime-types-data (3.2026.0127) sha256=4a58692436a987ad930e75bf8f24da7e627acfa0d06e1720aa514791b4c7d12b
+ mime-types-data (3.2026.0331) sha256=e9942b1fac72532e2b201b0c32c52e7650ef5ef8ca043a5054674597795c97a5
+ mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
- minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
+ minitest (6.0.3) sha256=88ac8a1de36c00692420e7cb3cc11a0773bbcb126aee1c249f320160a7d11411
+ mobility (1.3.2) sha256=32fbbb0e53118ef42de20daa6ac94dbb758c628874092eba311b968a1e1d757b
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
- net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
+ net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d
net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
- net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0
+ net-ssh (7.3.2) sha256=65029e213c380e20e5fd92ece663934ab0a0fe888e0cd7cc6a5b664074362dd4
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
- nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767
- nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
- nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c
- nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4
+ nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19
+ nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
+ nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
+ nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
oauth2 (2.0.18) sha256=bacf11e470dfb963f17348666d0a75c7b29ca65bc48fd47be9057cf91a403287
omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c
omniauth-github (2.0.1) sha256=8ff8e70ac6d6db9d52485eef52cfa894938c941496e66b52b5e2773ade3ccad4
omniauth-oauth2 (1.9.0) sha256=ed15f6d9d20991807ce114cc5b9c1453bce3645b64e51c68c90cff5ff153fee8
- omniauth-rails_csrf_protection (1.0.2) sha256=1170fd672aff092b9b7ebebc1453559f073ed001e3ce62a1df616e32f8dc5fe0
- orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9
+ omniauth-rails_csrf_protection (2.0.1) sha256=c6e3204d7e3925bb537cb52d50fdfc9f05293f1a9d87c5d4ab4ca3a39ba8c32d
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
- pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738
- parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
- parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
+ pagy (43.4.4) sha256=b41a57328a0aabfd222266a89e9de3dc3a735c17bd57f8113829c95fece5bef6
+ parallel (1.28.0) sha256=33e6de1484baf2524792d178b0913fc8eb94c628d6cfe45599ad4458c638c970
+ parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
- prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820
propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
- public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
- rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
+ rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rails (8.2.0.alpha)
rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
- rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560
+ rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89
+ rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3
railties (8.2.0.alpha)
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
- rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363
+ rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445
- regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
+ regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
- responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a
+ request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
- rorvswild (1.10.1) sha256=eb3f64611e48661f275747934961bf8585315702ee8913f220ccae3e65ac3f56
+ rorvswild (1.11.1) sha256=f2e2ade02c375e709cdd3c9f7f21601fb17db09399ab2a2f47fde72aa0609015
rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739
- rubocop (1.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a
- rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
+ rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186
+ rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
- ruby-openai (8.3.0) sha256=566dc279c42f4afed68a7a363dce2e594078abfc36b4e043102020b9a387ca69
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
+ ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
+ ruby_llm (1.14.1) sha256=7487d0f0bb9e86836d9233d656e10637370a6b22eb2555343c0a4c179ce7c500
+ ruby_llm-schema (0.3.0) sha256=a591edc5ca1b7f0304f0e2261de61ba4b3bea17be09f5cf7558153adfda3dec6
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
- selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b
+ selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22
snaky_hash (2.0.3) sha256=25a3d299566e8153fb02fa23fd9a9358845950f7a523ddbbe1fa1e0d79a6d456
solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
- solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428
+ solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a
sqlean (0.3.0-aarch64-linux-gnu) sha256=2b88dcefd7c9a92a9287c1bf8d650f286275d2645c95d5836c2efe8a0255a078
sqlean (0.3.0-arm64-darwin) sha256=32ffa1e5a908a52c028fb06fa2dbe61f600a865c95960d7ec4f3fbc82f28bf78
sqlean (0.3.0-x86_64-linux-gnu) sha256=51e7e0a66ceebf26c4a4509001412bea2214fb748752fde96a228db9cb2e85ce
sqlean (0.3.0-x86_64-linux-musl) sha256=93eb4f18679539b64c478dd2d57e393404c036e0057a0a93b2a8089ea6caa94a
- sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473
- sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3
- sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5
- sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90
+ sqlite3 (2.9.2-aarch64-linux-gnu) sha256=eeb86db55645b85327ba75129e3614658d974bf4da8fdc87018a0d42c59f6e42
+ sqlite3 (2.9.2-arm64-darwin) sha256=d15bd9609a05f9d54930babe039585efc8cadd57517c15b64ec7dfa75158a5e9
+ sqlite3 (2.9.2-x86_64-linux-gnu) sha256=dce83ffcb7e72f9f7aeb6e5404f15d277a45332fe18ccce8a8b3ed51e8d23aee
+ sqlite3 (2.9.2-x86_64-linux-musl) sha256=e8dd906a613f13b60f6d47ae9dda376384d9de1ab3f7e3f2fdf2fd18a871a2d7
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+ stripe (19.0.0) sha256=dc8cf11700638e6d0c0ca10063e7829ee90b26feb8e87811dcbbffc31645a656
tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916
- tailwindcss-ruby (4.1.18-aarch64-linux-gnu) sha256=e10f9560bccddbb4955fd535b3bcc8c7071a7df07404dd473a23fa791ec4e46b
- tailwindcss-ruby (4.1.18-arm64-darwin) sha256=f940531d5a030c566d3d616004235bcd4c361abdd328f7d6c7e3a953a32e0155
- tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534
- tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2
+ tailwindcss-ruby (4.2.1-aarch64-linux-gnu) sha256=de457ddfc999c6bbbe1a59fbc11eb2168d619f6e0cb72d8d3334d372b331e36f
+ tailwindcss-ruby (4.2.1-arm64-darwin) sha256=bcf222fb8542cf5433925623e5e7b257897fbb8291a2350daae870a32f2eeb91
+ tailwindcss-ruby (4.2.1-x86_64-linux-gnu) sha256=201d0e5e5d4aba52cae4ee4bd1acd497d2790c83e7f15da964aab8ec93876831
+ tailwindcss-ruby (4.2.1-x86_64-linux-musl) sha256=79fa48ad51e533545f9fdbb04227e1342a65a42c2bd1314118b95473d5612007
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
- thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba
- thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec
- thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
- tidewave (0.4.1) sha256=e33e0b5bd8678825fa00f2703ca64754d910996682f78b3420499068bc123258
- timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
+ thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397
+ thruster (0.1.20-arm64-darwin) sha256=630cf8c273f562063b92ea5ccd7a721d7ba6130cc422c823727f4744f6d0770e
+ thruster (0.1.20-x86_64-linux) sha256=d579f252bf67aee6ba6d957e48f566b72e019d7657ba2f267a5db1e4d91d2479
+ tidewave (0.4.2) sha256=e2c58ca43fa0b0d87f9825ab07f06add43e9ad8cf7c5aaa7b1166138d7b52bb8
+ timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
- turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4
- view_component (4.2.0) sha256=f250a3397a794336354f73c229b3b7549af0b24906551b99a03492b54cb5233d
- warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0
- web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20
- webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7
+ web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4
+ webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
wheretz (0.0.6) sha256=3ac9fa92aa4ff20c2b5e292f6ed041c9915a87ed5ddbf486cc94652a5554a0c7
xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
- zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
+ yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942
+ zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
BUNDLED WITH
4.0.5
diff --git a/README.md b/README.md
index 5604794..c88fbfd 100644
--- a/README.md
+++ b/README.md
@@ -138,7 +138,7 @@ Access the admin panel at `/admin`
### Background Jobs
- `GenerateSummaryJob`: Creates AI summaries for new content
-- `GenerateTestimonialFieldsJob`: AI-generates headline, subheadline, and quote from testimonials
+- `GenerateTestimonialJob`: AI-generates headline, subheadline, and quote from testimonials
- `ValidateTestimonialJob`: LLM-validates testimonial content
- `UpdateGithubDataJob`: Refreshes user GitHub data via GraphQL API
- `NormalizeLocationJob`: Geocodes user locations for the community map
diff --git a/app/assets/images/capedbot.svg b/app/assets/images/capedbot.svg
new file mode 100755
index 0000000..f613886
--- /dev/null
+++ b/app/assets/images/capedbot.svg
@@ -0,0 +1,27 @@
+
\ No newline at end of file
diff --git a/app/assets/images/icons/arrow-up.svg b/app/assets/images/icons/arrow-up.svg
new file mode 100644
index 0000000..00eed24
--- /dev/null
+++ b/app/assets/images/icons/arrow-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/chart.svg b/app/assets/images/icons/chart.svg
new file mode 100644
index 0000000..156d57e
--- /dev/null
+++ b/app/assets/images/icons/chart.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/chat.svg b/app/assets/images/icons/chat.svg
new file mode 100644
index 0000000..74f7a43
--- /dev/null
+++ b/app/assets/images/icons/chat.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/check-circle.svg b/app/assets/images/icons/check-circle.svg
new file mode 100644
index 0000000..309b5bc
--- /dev/null
+++ b/app/assets/images/icons/check-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/chevron-down.svg b/app/assets/images/icons/chevron-down.svg
new file mode 100644
index 0000000..3664435
--- /dev/null
+++ b/app/assets/images/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/chevron-left.svg b/app/assets/images/icons/chevron-left.svg
new file mode 100644
index 0000000..98100ed
--- /dev/null
+++ b/app/assets/images/icons/chevron-left.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/chevron-right.svg b/app/assets/images/icons/chevron-right.svg
new file mode 100644
index 0000000..7e3f638
--- /dev/null
+++ b/app/assets/images/icons/chevron-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/cog.svg b/app/assets/images/icons/cog.svg
new file mode 100644
index 0000000..0fb9a8a
--- /dev/null
+++ b/app/assets/images/icons/cog.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/assets/images/icons/computer.svg b/app/assets/images/icons/computer.svg
new file mode 100644
index 0000000..1ae3485
--- /dev/null
+++ b/app/assets/images/icons/computer.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/credit-card.svg b/app/assets/images/icons/credit-card.svg
new file mode 100644
index 0000000..bfae2ab
--- /dev/null
+++ b/app/assets/images/icons/credit-card.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/cube.svg b/app/assets/images/icons/cube.svg
new file mode 100644
index 0000000..c77bf87
--- /dev/null
+++ b/app/assets/images/icons/cube.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/dashboard.svg b/app/assets/images/icons/dashboard.svg
new file mode 100644
index 0000000..5ffd3b3
--- /dev/null
+++ b/app/assets/images/icons/dashboard.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/dollar.svg b/app/assets/images/icons/dollar.svg
new file mode 100644
index 0000000..9448265
--- /dev/null
+++ b/app/assets/images/icons/dollar.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/external-link.svg b/app/assets/images/icons/external-link.svg
new file mode 100644
index 0000000..4f1e3ae
--- /dev/null
+++ b/app/assets/images/icons/external-link.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/file.svg b/app/assets/images/icons/file.svg
new file mode 100644
index 0000000..8d85994
--- /dev/null
+++ b/app/assets/images/icons/file.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/folder.svg b/app/assets/images/icons/folder.svg
new file mode 100644
index 0000000..2ebc432
--- /dev/null
+++ b/app/assets/images/icons/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/github.svg b/app/assets/images/icons/github.svg
new file mode 100644
index 0000000..06d8ba0
--- /dev/null
+++ b/app/assets/images/icons/github.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/globe.svg b/app/assets/images/icons/globe.svg
new file mode 100644
index 0000000..c2b862d
--- /dev/null
+++ b/app/assets/images/icons/globe.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/assets/images/icons/home.svg b/app/assets/images/icons/home.svg
new file mode 100644
index 0000000..cd9b17d
--- /dev/null
+++ b/app/assets/images/icons/home.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/image.svg b/app/assets/images/icons/image.svg
new file mode 100644
index 0000000..84d5515
--- /dev/null
+++ b/app/assets/images/icons/image.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/key.svg b/app/assets/images/icons/key.svg
new file mode 100644
index 0000000..2d03609
--- /dev/null
+++ b/app/assets/images/icons/key.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/lightning.svg b/app/assets/images/icons/lightning.svg
new file mode 100644
index 0000000..f8aa050
--- /dev/null
+++ b/app/assets/images/icons/lightning.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/mail.svg b/app/assets/images/icons/mail.svg
new file mode 100644
index 0000000..86ca289
--- /dev/null
+++ b/app/assets/images/icons/mail.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/menu.svg b/app/assets/images/icons/menu.svg
new file mode 100644
index 0000000..2d22fc9
--- /dev/null
+++ b/app/assets/images/icons/menu.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/messages.svg b/app/assets/images/icons/messages.svg
new file mode 100644
index 0000000..29cfeb7
--- /dev/null
+++ b/app/assets/images/icons/messages.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/paperclip.svg b/app/assets/images/icons/paperclip.svg
new file mode 100644
index 0000000..8d20c4e
--- /dev/null
+++ b/app/assets/images/icons/paperclip.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/pencil.svg b/app/assets/images/icons/pencil.svg
new file mode 100644
index 0000000..ee2a66d
--- /dev/null
+++ b/app/assets/images/icons/pencil.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/plus.svg b/app/assets/images/icons/plus.svg
new file mode 100644
index 0000000..91822b8
--- /dev/null
+++ b/app/assets/images/icons/plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/shield.svg b/app/assets/images/icons/shield.svg
new file mode 100644
index 0000000..60b7935
--- /dev/null
+++ b/app/assets/images/icons/shield.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/sign-out.svg b/app/assets/images/icons/sign-out.svg
new file mode 100644
index 0000000..1935405
--- /dev/null
+++ b/app/assets/images/icons/sign-out.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/spinner.svg b/app/assets/images/icons/spinner.svg
new file mode 100644
index 0000000..d47c806
--- /dev/null
+++ b/app/assets/images/icons/spinner.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/assets/images/icons/trash.svg b/app/assets/images/icons/trash.svg
new file mode 100644
index 0000000..49d9b6e
--- /dev/null
+++ b/app/assets/images/icons/trash.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/user.svg b/app/assets/images/icons/user.svg
new file mode 100644
index 0000000..0500586
--- /dev/null
+++ b/app/assets/images/icons/user.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/icons/users.svg b/app/assets/images/icons/users.svg
new file mode 100644
index 0000000..91a336b
--- /dev/null
+++ b/app/assets/images/icons/users.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/wrench.svg b/app/assets/images/icons/wrench.svg
new file mode 100644
index 0000000..83e06fc
--- /dev/null
+++ b/app/assets/images/icons/wrench.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/x-circle.svg b/app/assets/images/icons/x-circle.svg
new file mode 100644
index 0000000..709c738
--- /dev/null
+++ b/app/assets/images/icons/x-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/icons/x.svg b/app/assets/images/icons/x.svg
new file mode 100644
index 0000000..6b8ab35
--- /dev/null
+++ b/app/assets/images/icons/x.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css
index 6f4967a..bb12afb 100644
--- a/app/assets/tailwind/application.css
+++ b/app/assets/tailwind/application.css
@@ -2,115 +2,789 @@
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
+/* ================================
+ WhyRuby-specific Components (light theme, public pages)
+ ================================ */
@layer components {
- /* Prose styles for Markdown content */
- .prose h1 {
- @apply text-3xl font-bold mb-4 mt-8;
+ .prose h1 { @apply text-3xl font-bold mb-4 mt-8; }
+ .prose h2 { @apply text-2xl font-bold mb-3 mt-6; }
+ .prose h3 { @apply text-xl font-bold mb-2 mt-4; }
+ .prose h4 { @apply uppercase; }
+ .prose p { @apply mb-4; }
+ .prose ul { @apply list-disc list-inside mb-4; }
+ .prose ol { @apply list-decimal list-inside mb-4; }
+ .prose li { @apply mb-1; }
+ .prose code { @apply bg-gray-100 px-1 py-0.5 rounded text-sm font-mono; }
+ .prose pre { @apply bg-gray-100 p-4 rounded overflow-x-auto mb-4; }
+ .prose pre code { @apply bg-transparent p-0; }
+ .prose blockquote { @apply border-l-4 border-gray-300 pl-4 italic mb-4; }
+ .prose a { @apply text-red-600 hover:text-red-800 underline; }
+ .prose strong { @apply font-bold; }
+ .prose em { @apply italic; }
+}
+
+@layer utilities {
+ /* Sticky header for tablets/desktops */
+ .sticky-tablet { position: static; }
+
+ @media (min-width: 768px) and (min-height: 600px) and (orientation: portrait) {
+ .sticky-tablet { position: sticky; }
}
- .prose h2 {
- @apply text-2xl font-bold mb-3 mt-6;
+ @media (min-width: 1400px) and (min-height: 600px) {
+ .sticky-tablet { position: sticky; }
}
- .prose h3 {
- @apply text-xl font-bold mb-2 mt-4;
+
+ /* Shorter map on landscape tablets */
+ @media (orientation: landscape) and (min-width: 768px) and (max-width: 1399px) {
+ .community-map-landscape { height: 400px; }
}
- .prose h4 {
- @apply uppercase;
+
+ @keyframes highlight-blink {
+ 0% { background-color: theme(colors.red.200); color: theme(colors.gray.900); }
+ 50% { background-color: theme(colors.red.200); color: theme(colors.gray.900); }
+ 100% { background-color: theme(colors.gray.50); color: inherit; }
}
- .prose p {
- @apply mb-4;
+
+ .comment-highlight:target .comment-content,
+ .comment-highlighting {
+ animation: highlight-blink 1s ease-in-out;
}
- .prose ul {
- @apply list-disc list-inside mb-4;
+
+ /* Override Tailwind Typography backtick decorations for inline code */
+ .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
+ .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,
+ .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
+ .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,
+ .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
+ .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,
+ .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
+ .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after {
+ content: none !important;
}
- .prose ol {
- @apply list-decimal list-inside mb-4;
+}
+
+/* Dark mode variant (class-based) */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* ================================
+ Theme configuration - OKLCH colors + fonts
+ (Tailwind utilities like bg-dark-500, text-accent-400, etc.)
+ ================================ */
+@theme {
+ --font-sans: "Inter var", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+
+ /* Dark theme palette - neutral greys (OKLCH) */
+ --color-dark-50: oklch(98% 0.005 250);
+ --color-dark-100: oklch(96% 0.005 250);
+ --color-dark-200: oklch(93% 0.005 250);
+ --color-dark-300: oklch(89% 0.005 250);
+ --color-dark-400: oklch(73% 0.01 250);
+ --color-dark-500: oklch(52% 0.015 250);
+ --color-dark-600: oklch(40% 0.015 250);
+ --color-dark-700: oklch(30% 0.015 250);
+ --color-dark-800: oklch(21% 0.015 250);
+ --color-dark-850: oklch(19% 0.015 250);
+ --color-dark-900: oklch(14% 0.01 250);
+ --color-dark-950: oklch(10% 0.01 250);
+
+ /* Accent colors (OKLCH) */
+ --color-accent-50: oklch(97% 0.02 260);
+ --color-accent-100: oklch(93% 0.05 260);
+ --color-accent-200: oklch(87% 0.08 260);
+ --color-accent-300: oklch(78% 0.13 260);
+ --color-accent-400: oklch(70% 0.17 260);
+ --color-accent-500: oklch(62% 0.21 260);
+ --color-accent-600: oklch(55% 0.22 260);
+ --color-accent-700: oklch(48% 0.22 260);
+}
+
+/* ================================
+ Madmin Dark Theme (all styles scoped under .dark)
+ ================================ */
+
+.body-bg {
+ @apply bg-dark-800 text-dark-100 min-h-screen;
+}
+
+.card-bg {
+ @apply bg-dark-700/50 rounded-xl shadow-lg shadow-black/10;
+}
+
+.select-transparent {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+ -webkit-appearance: none !important;
+ -moz-appearance: none !important;
+ appearance: none !important;
+ background-image: none !important;
+
+ .dark &.text-dark-400 {
+ color: var(--color-dark-400) !important;
}
- .prose li {
- @apply mb-1;
+}
+
+/* Dark-prefixed utility classes (only used in Madmin templates) */
+.dark-card {
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-600);
+ border-radius: 0.75rem;
+
+ &-header {
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--color-dark-600);
}
- .prose code {
- @apply bg-gray-100 px-1 py-0.5 rounded text-sm font-mono;
+
+ &-body {
+ padding: 1.5rem;
}
+}
+
+.dark-table {
+ width: 100%;
+ border-collapse: collapse;
- .prose pre {
- @apply bg-gray-100 p-4 rounded overflow-x-auto mb-4;
+ th {
+ background-color: var(--color-dark-700);
+ color: var(--color-dark-300);
+ font-weight: 500;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 0.75rem 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--color-dark-600);
}
- .prose pre code {
- @apply bg-transparent p-0;
+
+ td {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--color-dark-700);
+ color: var(--color-dark-200);
}
- .prose blockquote {
- @apply border-l-4 border-gray-300 pl-4 italic mb-4;
+
+ tbody tr:hover {
+ background-color: var(--color-dark-700);
}
- .prose a {
- @apply text-red-600 hover:text-red-800 underline;
+}
+
+.btn-dark {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-radius: 0.5rem;
+ transition: all 0.15s ease;
+
+ &-primary {
+ background: linear-gradient(135deg, oklch(62% 0.21 260) 0%, oklch(55% 0.22 260) 100%);
+ color: white;
+ border: none;
+
+ &:hover {
+ background: linear-gradient(135deg, oklch(55% 0.22 260) 0%, oklch(48% 0.22 260) 100%);
+ transform: translateY(-1px);
+ }
}
- .prose strong {
- @apply font-bold;
+
+ &-secondary {
+ background-color: var(--color-dark-700);
+ color: var(--color-dark-200);
+ border: 1px solid var(--color-dark-500);
+
+ &:hover {
+ background-color: var(--color-dark-600);
+ border-color: var(--color-dark-400);
+ }
}
- .prose em {
- @apply italic;
+
+ &-danger {
+ background-color: oklch(62% 0.24 25 / 10%);
+ color: oklch(70% 0.19 25);
+ border: 1px solid oklch(62% 0.24 25 / 30%);
+
+ &:hover {
+ background-color: oklch(62% 0.24 25 / 20%);
+ border-color: oklch(62% 0.24 25 / 50%);
+ }
}
}
-@layer utilities {
- /* Sticky header: not on phones, not on horizontal tablets */
- /* Horizontal phones: height < 600px → excluded */
- /* Horizontal iPads: landscape with width ≤ 1366px → excluded (all iPads max out at 1366px CSS width) */
- /* Desktops/laptops: typically 1440px+ wide → included */
- /* Vertical tablets (portrait): always included at 768px+ width */
- .sticky-tablet {
- position: static;
+/* ================================
+ Everything below is scoped to .dark (Madmin only)
+ ================================ */
+.dark {
+ /* CSS variables for dark theme */
+ --sidebar-bg: oklch(8% 0.02 260);
+ --sidebar-hover: oklch(100% 0 0 / 5%);
+ --sidebar-active: oklch(62% 0.21 260 / 15%);
+ --glow-blue: oklch(62% 0.21 260 / 40%);
+ --glow-purple: oklch(50% 0.27 300 / 40%);
+ --card-bg: oklch(23% 0.03 260 / 40%);
+ --card-shadow: 0 4px 24px oklch(0% 0 0 / 40%);
+ --card-border: oklch(100% 0 0 / 5%);
+
+ /* Dark prose for Madmin markdown */
+ .prose {
+ line-height: 1.7;
+ color: var(--color-dark-200);
+
+ p {
+ margin-top: 0.75rem;
+ margin-bottom: 0.75rem;
+ &:first-child { margin-top: 0; }
+ &:last-child { margin-bottom: 0; }
+ }
+
+ h1, h2, h3, h4 {
+ font-weight: 600;
+ margin-top: 1.5rem;
+ margin-bottom: 0.75rem;
+ color: var(--color-dark-50);
+ &:first-child { margin-top: 0; }
+ }
+
+ strong, b {
+ font-weight: 600;
+ color: var(--color-dark-100);
+ }
+
+ ul, ol { padding-left: 1.5rem; margin-top: 0.5rem; margin-bottom: 0.5rem; }
+ ul { list-style-type: disc; }
+ ol { list-style-type: decimal; }
+ li { margin-top: 0.25rem; margin-bottom: 0.25rem; }
+
+ blockquote {
+ border-left: 3px solid var(--color-dark-600);
+ padding-left: 1rem;
+ color: var(--color-dark-400);
+ font-style: italic;
+ margin: 1rem 0;
+ }
+
+ a {
+ color: oklch(70% 0.03 250);
+ text-decoration: underline;
+ &:hover { color: oklch(91% 0.02 250); }
+ }
+
+ table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
+ th, td { border: 1px solid var(--color-dark-700); padding: 0.5rem; text-align: left; }
+ th { background-color: var(--color-dark-700); font-weight: 600; color: var(--color-dark-100); }
+ td { background-color: var(--color-dark-800); }
+
+ code {
+ background-color: var(--color-dark-700);
+ color: oklch(75% 0.18 350);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ font-size: 0.875em;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ }
+
+ pre {
+ background-color: var(--color-dark-800);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ overflow-x: auto;
+ margin: 1rem 0;
+
+ code {
+ background-color: transparent;
+ padding: 0;
+ color: var(--color-dark-200);
+ }
+ }
}
- @media (min-width: 768px) and (min-height: 600px) and (orientation: portrait) {
- .sticky-tablet {
- position: sticky;
+ /* Dark Rouge syntax highlighting */
+ .highlight {
+ background-color: var(--color-dark-700);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ overflow-x: auto;
+ margin: 0.75rem 0;
+
+ pre { margin: 0; padding: 0; background: transparent; }
+ .c, .ch, .cd, .cm, .cpf, .c1, .cs { color: oklch(62% 0.14 140) !important; }
+ .k, .kc, .kd, .kn, .kp, .kr, .kt { color: oklch(68% 0.12 250) !important; }
+ .s, .sb, .sc, .dl, .sd, .s2, .sh, .sx, .s1, .ss { color: oklch(72% 0.1 50) !important; }
+ .na, .nb, .nc, .no, .nd, .ni, .ne, .nf, .nl, .nn, .nt, .nv { color: oklch(85% 0.1 220) !important; }
+ .m, .mb, .mf, .mh, .mi, .mo, .mx { color: oklch(82% 0.08 130) !important; }
+ .o, .ow { color: oklch(87% 0.01 250) !important; }
+ .p { color: oklch(87% 0.01 250) !important; }
+ .gi { color: oklch(75% 0.15 175) !important; }
+ .gd { color: oklch(62% 0.22 25) !important; }
+ .gh { color: oklch(68% 0.12 250) !important; font-weight: bold; }
+ .gu { color: oklch(68% 0.12 250) !important; }
+ .err { color: oklch(62% 0.22 25) !important; }
+ }
+
+ /* Dark scrollbar */
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
+ ::-webkit-scrollbar-track { background: var(--color-dark-800); }
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-dark-500);
+ border-radius: 4px;
+ &:hover { background: var(--color-dark-400); }
+ }
+
+ /* Dark form inputs */
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ input[type="search"],
+ input[type="url"],
+ input[type="date"],
+ input[type="datetime-local"],
+ textarea,
+ select {
+ background-color: var(--color-dark-700);
+ border-color: var(--color-dark-500);
+ color: var(--color-dark-100);
+
+ &:focus {
+ border-color: oklch(62% 0.21 260);
+ box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%);
}
}
- @media (min-width: 1400px) and (min-height: 600px) {
- .sticky-tablet {
- position: sticky;
+ input::placeholder,
+ textarea::placeholder {
+ color: var(--color-dark-500);
+ }
+
+ /* Global text colors for Madmin */
+ h1, h2, h3, h4, h5, h6 {
+ color: var(--color-dark-50);
+ }
+
+ p:not([class*="text-"]),
+ span:not([class*="text-"]):not(a span):not(a span span):not(button span),
+ div:not([class*="text-"]),
+ label:not([class*="text-"]) {
+ color: var(--color-dark-200);
+ }
+
+ a:not([class*="text-"]) {
+ color: var(--color-dark-100);
+ text-decoration: none;
+ }
+
+ /* Madmin Header */
+ .header {
+ background: transparent;
+ border: none;
+ margin-bottom: 1.5rem;
+
+ h1 { color: var(--color-dark-50); font-size: 1.875rem; font-weight: 700; }
+ .actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
+ }
+
+ /* Madmin Buttons */
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5625rem 1rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-radius: 0.5rem;
+ transition: all 0.15s ease;
+ border: 1px solid var(--color-dark-500);
+ background-color: var(--color-dark-700);
+ color: var(--color-dark-200);
+ text-decoration: none;
+ line-height: 1.5;
+
+ &:hover {
+ background-color: var(--color-dark-600);
+ border-color: var(--color-dark-400);
+ color: var(--color-dark-100);
+ }
+
+ &-primary, &.btn-primary {
+ background: linear-gradient(135deg, oklch(62% 0.21 260) 0%, oklch(55% 0.22 260) 100%);
+ border: none;
+ color: white;
+ &:hover { background: linear-gradient(135deg, oklch(55% 0.22 260) 0%, oklch(48% 0.22 260) 100%); color: white; }
+ }
+
+ &-secondary, &.btn-secondary {
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-500);
+ color: var(--color-dark-200);
+ &:hover { background-color: var(--color-dark-600); border-color: var(--color-dark-400); }
+ }
+
+ &-danger, &.btn-danger {
+ background-color: oklch(62% 0.24 25 / 15%);
+ border: 1px solid oklch(62% 0.24 25 / 30%);
+ color: oklch(70% 0.19 25);
+ &:hover { background-color: oklch(62% 0.24 25 / 25%); border-color: oklch(62% 0.24 25 / 50%); }
}
+
+ &-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
}
- /* Shorter map on landscape tablets so header text below is visible */
- @media (orientation: landscape) and (min-width: 768px) and (max-width: 1399px) {
- .community-map-landscape {
- height: 400px;
+ /* Madmin Filters */
+ .filters {
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-600);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ label { color: var(--color-dark-300); font-size: 0.875rem; }
+ }
+
+ /* Madmin Scopes */
+ .scopes {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+ .btn.active { background-color: var(--color-dark-600); border-color: var(--color-dark-400); color: var(--color-dark-50); }
+ }
+
+ /* Madmin Tables */
+ .table-scroll {
+ overflow-x: auto;
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-600);
+ border-radius: 0.75rem;
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+
+ th {
+ background-color: var(--color-dark-700);
+ color: var(--color-dark-400);
+ font-weight: 500;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 0.875rem 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--color-dark-600);
+
+ a:not([class*="text-"]) {
+ color: var(--color-dark-400);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ &:hover { color: var(--color-dark-200); }
+ }
+ }
+
+ td {
+ padding: 0.875rem 1rem;
+ border-bottom: 1px solid var(--color-dark-600);
+ color: var(--color-dark-400);
+
+ a:not([class*="text-"]) {
+ text-decoration: none;
+ &:hover { text-decoration: none; }
+ &:hover .font-medium { text-decoration: underline; }
+ }
+
+ a.rounded-full {
+ transition: filter 0.15s ease;
+ &:hover { filter: brightness(1.3); }
+ }
+ }
+
+ tbody tr {
+ &:hover { background-color: var(--color-dark-600); }
+ &:last-child td { border-bottom: none; }
}
}
- @keyframes highlight-blink {
- 0% {
- background-color: theme(colors.red.200);
- color: theme(colors.gray.900);
+ /* Madmin Pagination */
+ .pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 0;
+ flex-wrap: wrap;
+ gap: 1rem;
+
+ nav { display: flex; gap: 0.25rem; }
+
+ a, span {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2rem;
+ height: 2rem;
+ padding: 0 0.5rem;
+ font-size: 0.875rem;
+ border-radius: 0.375rem;
+ color: var(--color-dark-300);
+ text-decoration: none;
+ transition: all 0.15s ease;
}
- 50% {
- background-color: theme(colors.red.200);
- color: theme(colors.gray.900);
+
+ a:hover { background-color: var(--color-dark-700); color: var(--color-dark-100); }
+
+ a[disabled], span.current {
+ background-color: var(--color-dark-700);
+ color: var(--color-dark-50);
+ pointer-events: none;
}
- 100% {
- background-color: theme(colors.gray.50);
- color: inherit;
+
+ .pagy-info, .info { color: var(--color-dark-400); font-size: 0.875rem; }
+ }
+
+ /* Madmin Search */
+ .search {
+ display: flex;
+ align-items: center;
+
+ input[type="search"] {
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-500);
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ color: var(--color-dark-100);
+ font-size: 0.875rem;
+
+ &:focus {
+ outline: none;
+ border-color: oklch(62% 0.21 260);
+ box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%);
+ }
+
+ &::placeholder { color: var(--color-dark-500); }
}
}
-
- .comment-highlight:target .comment-content,
- .comment-highlighting {
- animation: highlight-blink 1s ease-in-out;
+
+ /* Madmin Forms */
+ .form-input,
+ .form-select,
+ input.form-input,
+ select.form-select {
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-500);
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ color: var(--color-dark-100);
+ font-size: 0.875rem;
+ line-height: 1.5;
+
+ &:focus {
+ outline: none;
+ border-color: oklch(62% 0.21 260);
+ box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%);
+ }
}
-
- /* Override Tailwind Typography's default backtick decorations for inline code */
- /* Use utilities layer to ensure it comes after typography plugin styles */
- .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
- .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,
- .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
- .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,
- .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
- .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,
- .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,
- .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after {
- content: none !important;
+
+ .form-field {
+ margin-bottom: 0;
+
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ input[type="url"],
+ input[type="date"],
+ input[type="datetime-local"],
+ input[type="time"],
+ textarea,
+ select {
+ width: 100%;
+ background-color: var(--color-dark-800);
+ border: 1px solid var(--color-dark-600);
+ border-radius: 0.5rem;
+ padding: 0.625rem 0.875rem;
+ color: var(--color-dark-100);
+ font-size: 0.9375rem;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+ &:hover { border-color: var(--color-dark-500); }
+ &:focus {
+ outline: none;
+ border-color: oklch(62% 0.21 260);
+ box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%);
+ }
+ &::placeholder { color: var(--color-dark-500); }
+ }
+
+ textarea { min-height: 100px; resize: vertical; }
+
+ select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737d8c' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
+ background-position: right 0.5rem center;
+ background-repeat: no-repeat;
+ background-size: 1.5em 1.5em;
+ padding-right: 2.5rem;
+
+ &[multiple] { background-image: none; padding-right: 0.875rem; min-height: 120px; }
+ option { padding: 0.5rem; background-color: var(--color-dark-700); }
+ }
+
+ input[type="checkbox"],
+ input[type="radio"] {
+ width: auto;
+ background-color: var(--color-dark-700);
+ border-color: var(--color-dark-500);
+ &:checked { background-color: oklch(62% 0.21 260); border-color: oklch(62% 0.21 260); }
+ }
+ }
+
+ .form-group {
+ margin-bottom: 1.5rem;
+
+ label { display: block; color: var(--color-dark-300); font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; }
+ .required { color: oklch(70% 0.19 25); margin-left: 0.25rem; }
+ .form-description { color: var(--color-dark-500); font-size: 0.8125rem; margin-top: 0.5rem; }
+
+ input, textarea, select {
+ width: 100%;
+ background-color: var(--color-dark-800);
+ border: 1px solid var(--color-dark-600);
+ border-radius: 0.5rem;
+ padding: 0.625rem 0.875rem;
+ color: var(--color-dark-100);
+ font-size: 0.9375rem;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+ &:focus {
+ outline: none;
+ border-color: oklch(62% 0.21 260);
+ box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%);
+ }
+ }
+ }
+
+ /* Madmin Show Page */
+ .show-page {
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-600);
+ border-radius: 0.75rem;
+ overflow: hidden;
+ }
+
+ .member-actions { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
+
+ .field-wrapper {
+ margin-bottom: 1.5rem;
+
+ label { display: block; color: var(--color-dark-300); font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; }
+
+ input, textarea, select {
+ width: 100%;
+ background-color: var(--color-dark-700);
+ border: 1px solid var(--color-dark-500);
+ border-radius: 0.5rem;
+ padding: 0.625rem 0.875rem;
+ color: var(--color-dark-100);
+ font-size: 0.9375rem;
+
+ &:focus {
+ outline: none;
+ border-color: oklch(62% 0.21 260);
+ box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%);
+ }
+ }
+ }
+
+ /* Madmin Flash messages */
+ .flash {
+ padding: 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+
+ &.notice, &.success {
+ background-color: oklch(72% 0.19 145 / 15%);
+ border: 1px solid oklch(72% 0.19 145 / 30%);
+ color: oklch(78% 0.17 145);
+ }
+
+ &.alert, &.error {
+ background-color: oklch(62% 0.24 25 / 15%);
+ border: 1px solid oklch(62% 0.24 25 / 30%);
+ color: oklch(70% 0.19 25);
+ }
+ }
+
+ /* Madmin pre/code elements */
+ pre, code { background-color: var(--color-dark-700); color: var(--color-dark-200); }
+ pre { padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; }
+ table pre, table code { background-color: transparent; margin: 0; padding: 0; white-space: pre-wrap; word-break: break-word; }
+}
+
+/* Link and button children should inherit color from parent */
+a[class*="text-"] span,
+a[class*="text-"] svg,
+button[class*="text-"] span,
+button[class*="text-"] svg {
+ color: inherit;
+}
+
+/* Chat textarea - fully transparent (Madmin) */
+#message_content,
+.chat-input textarea {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+
+ &:focus {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+ outline: none !important;
+ }
+}
+
+/* ================================
+ Collapsible Sidebar (Madmin)
+ ================================ */
+@media (min-width: 1024px) {
+ [data-sidebar-target="mainContent"] {
+ padding-left: 16rem;
+ transition: padding-left 0.3s ease-in-out;
+
+ &.sidebar-collapsed { padding-left: 4rem; }
+ }
+}
+
+aside.sidebar-collapsed {
+ .sidebar-nested-link { display: none; }
+
+ nav a,
+ nav > div > div > a,
+ .p-3 a,
+ .p-3 button,
+ .p-3 form button {
+ justify-content: center;
+ padding-left: 0;
+ padding-right: 0;
+ gap: 0;
+ }
+
+ .sidebar-group-header > a,
+ .sidebar-group-header > div {
+ justify-content: center;
+ padding-left: 0;
+ padding-right: 0;
+ gap: 0;
+ }
+
+ .sidebar-chevron { display: none; }
+
+ .sidebar-group-header > a > span,
+ .sidebar-group-header > div > span { gap: 0; }
+
+ .p-3 > .flex.items-center.gap-3.mb-3 { display: none; }
+
+ .sidebar-header {
+ justify-content: center;
+ & > a { display: none; }
}
}
diff --git a/app/avo/actions/bulk_delete.rb b/app/avo/actions/bulk_delete.rb
deleted file mode 100644
index 325e73a..0000000
--- a/app/avo/actions/bulk_delete.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-class Avo::Actions::BulkDelete < Avo::BaseAction
- self.name = "Bulk Delete"
- self.visible = -> { true }
- self.message = -> do
- count = case query
- when ActiveRecord::Relation
- query.count
- when Array
- query.size
- else
- # Single record
- 1
- end
- "Are you sure you want to delete #{pluralize(count, 'record')}? This action cannot be undone."
- end
- self.confirm_button_label = "Delete"
- self.cancel_button_label = "Cancel"
-
- def handle(query:, fields:, current_user:, resource:, **args)
- # Ensure query is always a collection
- records = case query
- when ActiveRecord::Relation
- query
- when Array
- query # Already a collection from our patch
- else
- [ query ] # Single record, wrap in array
- end
- count = records.is_a?(Array) ? records.size : records.count
-
- # Get the model name from the first record if available
- model_name = if records.any?
- records.first.class.name.underscore.humanize.downcase
- else
- "record"
- end
-
- begin
- # Delete each record individually to respect callbacks and associations
- records.each(&:destroy!)
-
- succeed "Successfully deleted #{count} #{model_name.pluralize(count)}."
- rescue => e
- error "Failed to delete records: #{e.message}"
- end
- end
-end
diff --git a/app/avo/actions/make_admin.rb b/app/avo/actions/make_admin.rb
deleted file mode 100644
index bc793a0..0000000
--- a/app/avo/actions/make_admin.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-class Avo::Actions::MakeAdmin < Avo::BaseAction
- self.name = "Make Admin"
- self.visible = -> { true }
- self.message = "Are you sure you want to make these users admins?"
-
- def handle(query:, fields:, current_user:, resource:, **args)
- # Ensure query is always a collection
- users = case query
- when ActiveRecord::Relation
- query
- when Array
- query # Already a collection from our patch
- else
- [ query ] # Single record, wrap in array
- end
-
- users.each do |user|
- user.update!(role: :admin)
- end
-
- count = users.is_a?(Array) ? users.size : users.count
- succeed "Successfully made #{count} #{'user'.pluralize(count)} admin."
- end
-end
diff --git a/app/avo/actions/regenerate_success_story_image.rb b/app/avo/actions/regenerate_success_story_image.rb
deleted file mode 100644
index 6b63794..0000000
--- a/app/avo/actions/regenerate_success_story_image.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-class Avo::Actions::RegenerateSuccessStoryImage < Avo::BaseAction
- self.name = "Regenerate Success Story Image"
- self.visible = -> {
- return false unless view == :show
-
- resource.record.success_story? && resource.record.logo_svg.present?
- }
-
- def handle(query:, fields:, current_user:, resource:, **args)
- eligible_posts = query.select { |post| post.success_story? && post.logo_svg.present? }
- jobs = eligible_posts.map { |post| GenerateSuccessStoryImageJob.new(post, force: true) }
- ActiveJob.perform_all_later(jobs)
-
- succeed "Image regeneration queued for #{eligible_posts.size} #{"post".pluralize(eligible_posts.size)}"
- end
-end
diff --git a/app/avo/actions/regenerate_summary.rb b/app/avo/actions/regenerate_summary.rb
deleted file mode 100644
index 0c2f648..0000000
--- a/app/avo/actions/regenerate_summary.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class Avo::Actions::RegenerateSummary < Avo::BaseAction
- self.name = "Regenerate AI Summary"
- self.visible = -> { true }
- self.message = "This will replace the existing summary with a new AI-generated one."
-
- def handle(query:, fields:, current_user:, resource:, **args)
- # Ensure query is always a collection
- posts = case query
- when ActiveRecord::Relation
- query
- when Array
- query # Already a collection from our patch
- else
- [ query ] # Single record, wrap in array
- end
-
- jobs = posts.map { |post| GenerateSummaryJob.new(post, force: true) }
- ActiveJob.perform_all_later(jobs)
-
- count = posts.is_a?(Array) ? posts.size : posts.count
- succeed "AI summary regeneration queued for #{count} #{'post'.pluralize(count)}."
- end
-end
diff --git a/app/avo/actions/toggle_published.rb b/app/avo/actions/toggle_published.rb
deleted file mode 100644
index 257f0e9..0000000
--- a/app/avo/actions/toggle_published.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class Avo::Actions::TogglePublished < Avo::BaseAction
- self.name = "Toggle Published"
- self.visible = -> { true }
-
- def handle(query:, fields:, current_user:, resource:, **args)
- # Ensure query is always a collection
- records = case query
- when ActiveRecord::Relation
- query
- when Array
- query # Already a collection from our patch
- else
- [ query ] # Single record, wrap in array
- end
-
- records.each do |record|
- record.update!(published: !record.published)
- end
-
- count = records.is_a?(Array) ? records.size : records.count
- succeed "Successfully toggled published status for #{count} #{'record'.pluralize(count)}."
- end
-end
diff --git a/app/avo/resources/category.rb b/app/avo/resources/category.rb
deleted file mode 100644
index d644b7f..0000000
--- a/app/avo/resources/category.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-class Avo::Resources::Category < Avo::BaseResource
- self.title = :name
- self.includes = []
- self.model_class = ::Category
- self.default_view_type = :table
- self.index_query = -> { query.unscoped }
- self.record_selector = -> { record.slug.presence || record.id }
-
-
-
- self.search = {
- query: -> { ::Category.unscoped.ransack(name_cont: params[:q]).result(distinct: false) }
- }
-
- # Override to find records without default scope and use FriendlyId with history support
- def self.find_record(id, **kwargs)
- # First try to find by current slug or ID
- ::Category.unscoped.friendly.find(id)
- rescue ActiveRecord::RecordNotFound
- # If not found, try to find by historical slug
- slug_record = FriendlyId::Slug.where(sluggable_type: "Category", slug: id).order(id: :desc).take
- if slug_record
- ::Category.unscoped.find(slug_record.sluggable_id)
- else
- # Last resort: try to find by ID directly (in case it's a ULID)
- ::Category.unscoped.find(id) rescue raise ActiveRecord::RecordNotFound.new("Couldn't find Category with 'id'=#{id}")
- end
- end
-
- # Handle finding multiple records for bulk actions
- def self.find_records(ids, **kwargs)
- return [] if ids.blank?
-
- # Handle both comma-separated string and array
- id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids
-
- # Find each record individually to support slugs
- id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact
- end
-
- def fields
- field :id, as: :text, readonly: true
- field :name, as: :text, required: true
- field :description, as: :textarea, rows: 3, placeholder: "Describe what this category is about..."
- field :is_success_story, as: :boolean,
- help: "This flag cannot be changed once set. Only one category can be marked as the Success Story category.",
- readonly: -> { record.persisted? && record.is_success_story? }
- field :position, as: :number, required: true
- field :created_at, as: :date_time, readonly: true
- field :updated_at, as: :date_time, readonly: true
-
- # Associations
- field :posts, as: :has_many
- end
-
- def actions
- action Avo::Actions::BulkDelete
- end
-end
diff --git a/app/avo/resources/comment.rb b/app/avo/resources/comment.rb
deleted file mode 100644
index 605f1fa..0000000
--- a/app/avo/resources/comment.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-class Avo::Resources::Comment < Avo::BaseResource
- self.title = :body
- self.includes = [ :user, :post ]
- self.index_query = -> { query.unscoped }
-
- self.search = {
- query: -> { Comment.unscoped.ransack(body_cont: params[:q]).result(distinct: false) }
- }
-
- # Override to find records without default scope
- def self.find_record(id, **kwargs)
- ::Comment.unscoped.find(id)
- end
-
- def fields
- field :id, as: :text, readonly: true, hide_on: [ :index ]
-
- # Show truncated body on index
- field :body, as: :text,
- required: true,
- only_on: [ :index ],
- format_using: -> { value.to_s.truncate(100) if value },
- link_to_record: true
-
- # Full body for other views
- field :body, as: :textarea, required: true, hide_on: [ :index ]
-
- # Associations - show on index for context
- field :user, as: :belongs_to
- field :post, as: :belongs_to,
- format_using: -> { record.post&.title&.truncate(50) if view == :index }
-
- # Status
- field :published, as: :boolean
-
- # Timestamps
- field :created_at, as: :date_time, readonly: true, hide_on: [ :index ]
- field :updated_at, as: :date_time, readonly: true, only_on: [ :index ]
- end
-
- def actions
- action Avo::Actions::TogglePublished
- action Avo::Actions::BulkDelete
- end
-end
diff --git a/app/avo/resources/post.rb b/app/avo/resources/post.rb
deleted file mode 100644
index 1999435..0000000
--- a/app/avo/resources/post.rb
+++ /dev/null
@@ -1,271 +0,0 @@
-class Avo::Resources::Post < Avo::BaseResource
- self.title = :title
- self.includes = [ :user, :category, :tags, :comments ]
- self.model_class = ::Post
- self.index_query = -> { query.unscoped }
- self.description = "Manage all posts in the system"
- self.default_view_type = :table
- self.record_selector = -> { record.slug.presence || record.id }
-
- self.search = {
- query: -> { Post.unscoped.ransack(title_cont: params[:q], content_cont: params[:q], m: "or").result(distinct: false) }
- }
-
- # Override to find records without default scope and use FriendlyId with history support
- def self.find_record(id, **kwargs)
- # First try to find by current slug or ID
- ::Post.unscoped.friendly.find(id)
- rescue ActiveRecord::RecordNotFound
- # If not found, try to find by historical slug
- slug_record = FriendlyId::Slug.find_by(sluggable_type: "Post", slug: id)
- if slug_record
- ::Post.unscoped.find(slug_record.sluggable_id)
- else
- raise ActiveRecord::RecordNotFound
- end
- end
-
- # Handle finding multiple records for bulk actions
- def self.find_records(ids, **kwargs)
- return [] if ids.blank?
-
- # Handle both comma-separated string and array
- id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids
-
- # Find each record individually to support slugs
- id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact
- end
-
- def fields
- # Compact ID display - only show last 8 chars on index
- field :id, as: :text, readonly: true, hide_on: [ :index ]
-
- # Main content fields
- field :title, as: :text, required: true, link_to_record: true
-
- # User with avatar - custom display for index
- field :user, as: :belongs_to,
- only_on: [ :forms, :show ]
-
- field :user_with_avatar,
- as: :text,
- name: "User",
- only_on: [ :index ],
- format_using: -> do
- if record.user
- avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0"
- link_to view_context.avo.resources_user_path(record.user),
- class: "flex items-center gap-2 hover:underline" do
- image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) +
- content_tag(:span, record.user.username)
- end
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
-
- field :category, as: :belongs_to,
- help: "Success stories are auto-assigned to the Success Stories category",
- readonly: -> { record.post_type == "success_story" }
-
- # Post type and success story fields
- field :post_type, as: :select,
- options: { article: "Article", link: "Link", success_story: "Success Story" }.invert,
- hide_on: [ :index ]
-
- field :logo_svg, as: :textarea,
- name: "Logo SVG",
- hide_on: [ :index ],
- rows: 10,
- help: "SVG code for success story logo (only used for Success Story posts)"
-
- # Status badges for index view
- field :published,
- as: :text,
- name: "Published",
- only_on: [ :index ],
- format_using: -> do
- if record.published
- content_tag(:span, class: "inline-flex items-center text-green-600") do
- # Simple checkmark for consistency
- content_tag(:svg, xmlns: "http://www.w3.org/2000/svg",
- class: "w-4 h-4",
- viewBox: "0 0 20 20",
- fill: "currentColor") do
- content_tag(:path, nil,
- "fill-rule": "evenodd",
- d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",
- "clip-rule": "evenodd")
- end
- end
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
-
- field :published, as: :boolean, hide_on: [ :index ]
- field :pin_position, as: :number, hide_on: [ :index ]
-
- # Needs Review with custom icons
- field :needs_admin_review,
- as: :text,
- name: "Needs Review",
- only_on: [ :index ],
- format_using: -> do
- if record.needs_admin_review
- content_tag(:span, class: "inline-flex items-center text-red-600") do
- # Exclamation mark in circle for "yes"
- content_tag(:svg, xmlns: "http://www.w3.org/2000/svg",
- class: "w-4 h-4",
- viewBox: "0 0 20 20",
- fill: "currentColor") do
- content_tag(:path, nil,
- "fill-rule": "evenodd",
- d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z",
- "clip-rule": "evenodd")
- end
- end
- else
- content_tag(:span, class: "inline-flex items-center text-green-600") do
- # Simple checkmark for consistency with Published field
- content_tag(:svg, xmlns: "http://www.w3.org/2000/svg",
- class: "w-4 h-4",
- viewBox: "0 0 20 20",
- fill: "currentColor") do
- content_tag(:path, nil,
- "fill-rule": "evenodd",
- d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",
- "clip-rule": "evenodd")
- end
- end
- end
- end
-
- field :needs_admin_review, as: :boolean, hide_on: [ :index ]
-
- # URL field for forms - plain text input
- field :url, as: :text,
- only_on: [ :forms ],
- placeholder: "https://example.com/article"
-
- # URL field for show view - formatted as link
- field :url, as: :text,
- only_on: [ :show ],
- format_using: -> { link_to(value, value, target: "_blank", class: "text-blue-600 hover:underline") if value.present? }
-
- # Add a compact URL field just for index
- field :url, as: :text,
- name: "Link",
- only_on: [ :index ],
- format_using: -> do
- if value.present?
- domain = URI.parse(value).host rescue "External"
- link_to(domain || "Link", value, target: "_blank", class: "text-blue-600 hover:underline")
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
-
- # Long content fields - hide from index
- field :content, as: :textarea, hide_on: [ :index ], rows: 20
- field :summary, as: :textarea, hide_on: [ :index ], rows: 5
- field :title_image_url, as: :text, hide_on: [ :index ]
-
- # Featured image upload without auto-preview/variants (Avo should not generate variants)
- field :featured_image, as: :file,
- name: "Featured Image",
- hide_on: [ :index ],
- accept: "image/*",
- is_image: false,
- help: "Generated automatically for success stories with logos"
-
- # Read-only preview that uses our pre-generated WebP blobs (no ActiveStorage variants)
- field :processed_image_preview,
- as: :text,
- name: "Image Preview",
- only_on: [ :show ],
- format_using: -> do
- if record.featured_image.attached?
- ApplicationController.helpers.post_image_tag(record, size: :post, css_class: "max-w-md border border-gray-300 rounded shadow-sm")
- else
- content_tag(:span, "No image", class: "text-gray-400")
- end
- end
-
- # OG Image Preview
- field :og_image_preview,
- as: :text,
- name: "OG Image Preview",
- only_on: [ :show ],
- format_using: -> do
- # Generate the OG image URL
- og_url = if record.featured_image.attached?
- "#{view_context.request.base_url}/#{record.category.to_param}/#{record.to_param}/og-image.png?v=#{record.updated_at.to_i}"
- else
- # Default OG image with version based on file modification time
- og_image_path = Rails.root.join("public", "og-image.png")
- version = File.exist?(og_image_path) ? File.mtime(og_image_path).to_i.to_s : Time.current.to_i.to_s
- "#{view_context.request.base_url}/og-image.png?v=#{version}"
- end
-
- # Build the HTML without concat to avoid duplication
- image_link = link_to(og_url, target: "_blank", class: "inline-block") do
- image_tag(og_url,
- class: "max-w-md border border-gray-300 rounded shadow-sm hover:shadow-md transition-shadow",
- alt: "OG Image Preview")
- end
-
- url_display = content_tag(:div, class: "text-sm text-gray-600") do
- "URL: #{link_to(og_url, og_url, target: '_blank', class: 'text-blue-600 hover:underline break-all')}".html_safe
- end
-
- content_tag(:div, class: "space-y-2") do
- "#{image_link}#{url_display}".html_safe
- end
- end
-
- # Counts and metadata
- field :reports_count, as: :number, readonly: true, hide_on: [ :index ]
-
- # Clickable comments count that leads to filtered comments
- field :comments,
- as: :text,
- name: "Comments",
- only_on: [ :index ],
- format_using: -> do
- count = record.comments.count
- if count > 0
- link_to count.to_s,
- view_context.avo.resources_comments_path(via_record_id: record.id, via_resource_class: "Avo::Resources::Post"),
- class: "text-blue-600 hover:underline font-medium",
- title: "View comments for this post"
- else
- content_tag(:span, "0", class: "text-gray-400")
- end
- end
-
- # Timestamps - show only updated_at on index
- field :created_at, as: :date_time, readonly: true, hide_on: [ :index ]
- field :updated_at, as: :date_time, readonly: true
-
- # Associations - hide from index to save space
- field :tags, as: :has_and_belongs_to_many, hide_on: [ :index ]
- field :comments, as: :has_many, hide_on: [ :index ]
- field :reports, as: :has_many, hide_on: [ :index ]
- end
-
- def actions
- action Avo::Actions::TogglePublished
- action Avo::Actions::RegenerateSummary
- action Avo::Actions::RegenerateSuccessStoryImage
- action Avo::Actions::BulkDelete
- # action Avo::Actions::PinContent
- # action Avo::Actions::ClearReports
- end
-
- # def filters
- # filter Avo::Filters::Published
- # filter Avo::Filters::NeedsReview
- # filter Avo::Filters::ContentType
- # end
-end
diff --git a/app/avo/resources/project.rb b/app/avo/resources/project.rb
deleted file mode 100644
index e92d3a4..0000000
--- a/app/avo/resources/project.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-class Avo::Resources::Project < Avo::BaseResource
- self.title = :name
- self.includes = [ :user, :star_snapshots ]
- self.model_class = ::Project
- self.description = "Manage GitHub projects (repositories) for users"
- self.default_view_type = :table
-
- self.search = {
- query: -> { Project.ransack(name_cont: params[:q], github_url_cont: params[:q], m: "or").result(distinct: false) }
- }
-
- def fields
- field :id, as: :text, readonly: true, hide_on: [ :index ]
-
- field :name, as: :text, link_to_record: true
-
- field :user, as: :belongs_to,
- only_on: [ :forms, :show ]
-
- field :user_with_avatar,
- as: :text,
- name: "User",
- only_on: [ :index ],
- format_using: -> do
- if record.user
- avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0"
- link_to view_context.avo.resources_user_path(record.user),
- class: "flex items-center gap-2 hover:underline" do
- image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) +
- content_tag(:span, record.user.username)
- end
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
-
- field :stars, as: :number, only_on: [ :forms, :show ]
- field :stars_with_trend,
- as: :text,
- name: "Stars",
- only_on: [ :index ],
- sortable: -> { query.order(stars: direction) },
- format_using: -> do
- gained = record.stars_gained
- trend = gained > 0 ? content_tag(:span, " +#{gained}", class: "text-green-600") : ""
- safe_join([ record.stars.to_s, trend ])
- end
- field :github_url, as: :text, only_on: [ :forms ]
- field :github_url, as: :text, name: "GitHub", only_on: [ :index ],
- format_using: -> do
- if value.present?
- repo_name = value.split("/").last(2).join("/")
- link_to(repo_name, value, target: "_blank", class: "text-blue-600 hover:underline")
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
- field :github_url, as: :text, only_on: [ :show ],
- format_using: -> { link_to(value, value, target: "_blank", class: "text-blue-600 hover:underline") if value.present? }
-
- field :description, as: :textarea, hide_on: [ :index ], rows: 3
- field :forks_count, as: :number, hide_on: [ :index ]
- field :size, as: :number, hide_on: [ :index ]
- field :topics, as: :text, hide_on: [ :index ],
- format_using: -> { value.is_a?(Array) ? value.join(", ") : value.to_s }
-
- field :hidden, as: :boolean
- field :archived, as: :boolean
-
- field :pushed_at, as: :date_time, readonly: true, hide_on: [ :index ]
- field :created_at, as: :date_time, readonly: true, hide_on: [ :index ]
- field :updated_at, as: :date_time, readonly: true
-
- field :star_snapshots, as: :has_many, hide_on: [ :index ]
- end
-end
diff --git a/app/avo/resources/report.rb b/app/avo/resources/report.rb
deleted file mode 100644
index a7f18fe..0000000
--- a/app/avo/resources/report.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-class Avo::Resources::Report < Avo::BaseResource
- self.title = :reason
- self.includes = [ :user, :post ]
-
- # Reports don't have archived field, so no need for unscoped
-
- def fields
- field :id, as: :text, readonly: true
- field :reason, as: :select, enum: ::Report.reasons
- field :description, as: :textarea
- field :created_at, as: :date_time, readonly: true
-
- # Associations
- field :user, as: :belongs_to
- field :post, as: :belongs_to
- end
-
- def actions
- action Avo::Actions::BulkDelete
- end
-end
diff --git a/app/avo/resources/star_snapshot.rb b/app/avo/resources/star_snapshot.rb
deleted file mode 100644
index bbf875f..0000000
--- a/app/avo/resources/star_snapshot.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-class Avo::Resources::StarSnapshot < Avo::BaseResource
- self.title = :recorded_on
- self.includes = [ :project ]
- self.model_class = ::StarSnapshot
- self.description = "Daily star count snapshots for projects"
- self.default_view_type = :table
-
- def fields
- field :id, as: :text, readonly: true, hide_on: [ :index ]
- field :project, as: :belongs_to
- field :stars, as: :number
- field :recorded_on, as: :date
- field :created_at, as: :date_time, readonly: true, hide_on: [ :index ]
- end
-end
diff --git a/app/avo/resources/tag.rb b/app/avo/resources/tag.rb
deleted file mode 100644
index 564436f..0000000
--- a/app/avo/resources/tag.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-class Avo::Resources::Tag < Avo::BaseResource
- self.title = :name
- self.includes = []
- self.index_query = -> { query.unscoped }
- self.record_selector = -> { record.slug.presence || record.id }
-
- self.search = {
- query: -> { Tag.unscoped.ransack(name_cont: params[:q]).result(distinct: false) }
- }
-
- # Override to find records without default scope and use FriendlyId with history support
- def self.find_record(id, **kwargs)
- # First try to find by current slug or ID
- ::Tag.unscoped.friendly.find(id)
- rescue ActiveRecord::RecordNotFound
- # If not found, try to find by historical slug
- slug_record = FriendlyId::Slug.find_by(sluggable_type: "Tag", slug: id)
- if slug_record
- ::Tag.unscoped.find(slug_record.sluggable_id)
- else
- raise ActiveRecord::RecordNotFound
- end
- end
-
- # Handle finding multiple records for bulk actions
- def self.find_records(ids, **kwargs)
- return [] if ids.blank?
-
- # Handle both comma-separated string and array
- id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids
-
- # Find each record individually to support slugs
- id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact
- end
-
- def fields
- field :id, as: :text, readonly: true
- field :name, as: :text, required: true
- field :created_at, as: :date_time, readonly: true
- field :updated_at, as: :date_time, readonly: true
-
- # Associations
- field :posts, as: :has_and_belongs_to_many
- end
-
- def actions
- action Avo::Actions::BulkDelete
- end
-end
diff --git a/app/avo/resources/testimonial.rb b/app/avo/resources/testimonial.rb
deleted file mode 100644
index 522f336..0000000
--- a/app/avo/resources/testimonial.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-class Avo::Resources::Testimonial < Avo::BaseResource
- self.title = :heading
- self.includes = [ :user ]
- self.model_class = ::Testimonial
- self.description = "Manage user testimonials"
- self.default_view_type = :table
-
- def fields
- field :id, as: :text, readonly: true, hide_on: [ :index ]
-
- field :user_with_avatar,
- as: :text,
- name: "User",
- only_on: [ :index ],
- format_using: -> do
- if record.user
- avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0"
- link_to view_context.avo.resources_user_path(record.user),
- class: "flex items-center gap-2 hover:underline" do
- image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) +
- content_tag(:span, record.user.username)
- end
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
-
- field :user, as: :belongs_to, only_on: [ :forms, :show ]
-
- field :heading, as: :text, link_to_record: true
- field :subheading, as: :text, hide_on: [ :index ]
- field :quote, as: :textarea, rows: 4
- field :body_text, as: :textarea, rows: 6, hide_on: [ :index ]
-
- field :published,
- as: :text,
- name: "Published",
- only_on: [ :index ],
- format_using: -> do
- if record.published
- content_tag(:span, class: "inline-flex items-center text-green-600") do
- content_tag(:svg, xmlns: "http://www.w3.org/2000/svg",
- class: "w-4 h-4",
- viewBox: "0 0 20 20",
- fill: "currentColor") do
- content_tag(:path, nil,
- "fill-rule": "evenodd",
- d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",
- "clip-rule": "evenodd")
- end
- end
- else
- content_tag(:span, "-", class: "text-gray-400")
- end
- end
-
- field :published, as: :boolean, hide_on: [ :index ]
- field :position, as: :number
- field :ai_feedback, as: :textarea, rows: 3, hide_on: [ :index ]
- field :ai_attempts, as: :number, readonly: true, hide_on: [ :index ]
-
- field :created_at, as: :date_time, readonly: true, hide_on: [ :index ]
- field :updated_at, as: :date_time, readonly: true
- end
-end
diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb
deleted file mode 100644
index 9e86f86..0000000
--- a/app/avo/resources/user.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-class Avo::Resources::User < Avo::BaseResource
- self.title = :username
- self.includes = [ :posts, :comments ]
- self.index_query = -> { query.unscoped }
- self.description = "Manage system users"
- self.record_selector = -> { record.slug.presence || record.id }
-
- self.search = {
- query: -> { User.unscoped.ransack(username_cont: params[:q], email_cont: params[:q], m: "or").result(distinct: false) }
- }
-
- # Override to find records without default scope and use FriendlyId with history support
- def self.find_record(id, **kwargs)
- # First try to find by current slug or ID
- ::User.unscoped.friendly.find(id)
- rescue ActiveRecord::RecordNotFound
- # If not found, try to find by historical slug
- slug_record = FriendlyId::Slug.find_by(sluggable_type: "User", slug: id)
- if slug_record
- ::User.unscoped.find(slug_record.sluggable_id)
- else
- raise ActiveRecord::RecordNotFound
- end
- end
-
- # Handle finding multiple records for bulk actions
- def self.find_records(ids, **kwargs)
- return [] if ids.blank?
-
- # Handle both comma-separated string and array
- id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids
-
- # Find each record individually to support slugs
- id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact
- end
-
- def fields
- # Compact display for index
- field :id, as: :text, readonly: true, hide_on: [ :index ]
- field :avatar_url, as: :external_image, link_to_record: true, circular: true, size: :sm
- field :username, as: :text, readonly: true, link_to_record: true
- field :email, as: :text, readonly: true, hide_on: [ :index ]
- field :role, as: :select, enum: ::User.roles
-
- # Activity indicators for index
- field :published_posts_count, as: :number, readonly: true, name: "Posts"
- field :published_comments_count, as: :number, readonly: true, name: "Comments", hide_on: [ :forms, :show ]
-
- # Status
- field :trusted?, as: :boolean, readonly: true, name: "Trusted"
-
- # GitHub info - hide from index
- field :github_id, as: :number, readonly: true, hide_on: [ :index ]
-
- # Timestamps - only show created_at on index
- field :created_at, as: :date_time, readonly: true
- field :updated_at, as: :date_time, readonly: true, hide_on: [ :index ]
-
- # Newsletter
- field :unsubscribed_from_newsletter, as: :boolean, readonly: true, hide_on: [ :index ]
- field :newsletters_received, as: :text, readonly: true, hide_on: [ :index ], format_using: -> { value.is_a?(Array) ? value.map { |v| NewsletterMailer::SUBJECTS[v] || "v#{v}" }.join(", ") : value.to_s }
- field :newsletters_opened, as: :text, readonly: true, hide_on: [ :index ], format_using: -> { value.is_a?(Array) ? value.map { |v| NewsletterMailer::SUBJECTS[v] || "v#{v}" }.join(", ") : value.to_s }
-
- # Associations - hide from index
- field :posts, as: :has_many, hide_on: [ :index ]
- field :comments, as: :has_many, hide_on: [ :index ]
- field :reports, as: :has_many, hide_on: [ :index ]
- end
-
- def actions
- action Avo::Actions::MakeAdmin
- action Avo::Actions::BulkDelete
- end
-end
diff --git a/app/controllers/admins/sessions/verifications_controller.rb b/app/controllers/admins/sessions/verifications_controller.rb
new file mode 100644
index 0000000..1c49b65
--- /dev/null
+++ b/app/controllers/admins/sessions/verifications_controller.rb
@@ -0,0 +1,15 @@
+class Admins::Sessions::VerificationsController < ApplicationController
+ rate_limit to: 5, within: 5.minutes, name: "admin_sessions/verify",
+ with: -> { redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.rate_limit.verify") }
+
+ layout "admin_auth"
+
+ def show
+ admin = Admin.find_signed!(params[:token], purpose: :magic_link)
+ session[:admin_id] = admin.id
+
+ redirect_to "/madmin", notice: t("controllers.admins.sessions.verify.notice")
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
+ redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.verify.alert")
+ end
+end
diff --git a/app/controllers/admins/sessions_controller.rb b/app/controllers/admins/sessions_controller.rb
new file mode 100644
index 0000000..f9c127b
--- /dev/null
+++ b/app/controllers/admins/sessions_controller.rb
@@ -0,0 +1,33 @@
+class Admins::SessionsController < ApplicationController
+ # Admin login and magic link verification
+ # All admin management happens through Madmin at /madmin
+
+ # Stricter limits for admin authentication
+ rate_limit to: 3, within: 1.minute, name: "admin_sessions/short", only: :create,
+ with: -> { redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.rate_limit.short") }
+ rate_limit to: 10, within: 1.hour, name: "admin_sessions/long", only: :create,
+ with: -> { redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.rate_limit.long") }
+ layout "admin_auth", only: [ :new, :create ]
+
+ def new
+ # Show admin login form
+ end
+
+ def create
+ email = params.expect(session: :email)[:email]
+ admin = Admin.find_by(email: email)
+
+ if admin
+ # Send magic link to existing admin
+ AdminMailer.magic_link(admin).deliver_later
+ redirect_to new_admins_session_path, notice: t("controllers.admins.sessions.create.notice")
+ else
+ redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.create.alert")
+ end
+ end
+
+ def destroy
+ reset_session
+ redirect_to root_path, notice: t("controllers.admins.sessions.destroy.notice")
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 931132e..bc12150 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,19 +1,138 @@
class ApplicationController < ActionController::Base
+ rate_limit to: 100, within: 1.minute, name: "global"
+
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
- protected
+ before_action :set_current_user
+ before_action :set_locale
+ before_action :set_current_team, if: :team_scoped_request?
+
+ private
+
+ # ── Session-based authentication ──
+
+ def current_user
+ @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
+ end
+ helper_method :current_user
+
+ def user_signed_in?
+ current_user.present?
+ end
+ helper_method :user_signed_in?
+
+ def sign_in(user)
+ reset_session
+ session[:user_id] = user.id
+ @current_user = user
+ end
+
+ def sign_out(_user = nil)
+ reset_session
+ @current_user = nil
+ end
+
+ def authenticate_user!
+ unless user_signed_in?
+ session[:return_to] = request.original_url if request.get?
+ redirect_to github_auth_with_return_path, alert: "Please sign in with GitHub to continue."
+ end
+ end
+
+ # ── Current attributes ──
+
+ def set_current_user
+ Current.user = current_user if defined?(Current) && Current.respond_to?(:user=)
+ end
+
+ # ── Locale ──
+
+ def set_locale
+ I18n.locale = detect_locale
+ end
+
+ def detect_locale
+ if current_user&.respond_to?(:locale) && current_user.locale.present?
+ return current_user.locale.to_sym
+ end
+
+ if request.headers["Accept-Language"] && defined?(Language)
+ accepted = parse_accept_language(request.headers["Accept-Language"])
+ enabled = Language.enabled_codes
+
+ accepted.each do |code|
+ return code.to_sym if enabled.include?(code)
+ end
+ end
+
+ I18n.default_locale
+ end
+
+ def detected_locale
+ I18n.locale
+ end
+ helper_method :detected_locale
- def after_sign_in_path_for(resource)
- # Check if there's a stored return path
- return_to = session.delete(:return_to)
- return return_to if return_to.present?
+ # ── Team scoping ──
- # Otherwise redirect to user's profile after sign in
- if resource.is_a?(User)
- user_path(resource)
- else
- super
+ def set_current_team
+ return unless current_user
+
+ @current_team = current_user.teams.find_by(slug: params[:team_slug])
+
+ unless @current_team
+ redirect_to teams_path, alert: t("controllers.application.team_not_found", default: "Team not found")
+ return
end
+
+ Current.team = @current_team if defined?(Current) && Current.respond_to?(:team=)
+ Current.membership = current_user.membership_for(@current_team) if defined?(Current) && Current.respond_to?(:membership=)
+ end
+
+ def team_scoped_request?
+ params[:team_slug].present?
+ end
+
+ def current_team
+ @current_team
+ end
+ helper_method :current_team
+
+ def current_membership
+ current_user&.membership_for(@current_team) if @current_team
+ end
+ helper_method :current_membership
+
+ def current_admin
+ @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id]
+ end
+ helper_method :current_admin
+
+ def authenticate_admin!
+ redirect_to root_path, alert: "Admin access required" unless current_user&.admin?
+ end
+
+ def require_team_admin!
+ unless current_membership&.admin?
+ redirect_to team_root_path(current_team), alert: t("controllers.application.admin_required", default: "Admin access required")
+ end
+ end
+
+ def require_subscription!
+ return unless current_team
+ return if current_team.subscription_active?
+
+ redirect_to team_pricing_path(current_team),
+ alert: t("controllers.application.subscription_required", default: "Subscription required")
+ end
+
+ def parse_accept_language(header)
+ header.to_s.split(",").filter_map { |entry|
+ lang, quality = entry.strip.split(";")
+ code = lang&.strip&.split("-")&.first&.downcase
+ q = quality ? quality.strip.delete_prefix("q=").to_f : 1.0
+ [ code, q ] if code.present?
+ }.sort_by { |_, q| -q }.map(&:first).uniq
end
end
diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb
new file mode 100644
index 0000000..0284c47
--- /dev/null
+++ b/app/controllers/articles_controller.rb
@@ -0,0 +1,52 @@
+class ArticlesController < ApplicationController
+ before_action :authenticate_user!
+ before_action :set_article, only: [ :show, :edit, :update, :destroy ]
+
+ def index
+ @articles = current_team.articles.includes(:user).recent
+ end
+
+ def show
+ end
+
+ def new
+ @article = current_team.articles.new
+ end
+
+ def create
+ @article = current_team.articles.new(article_params)
+ @article.user = current_user
+
+ if @article.save
+ redirect_to team_article_path(current_team, @article), notice: t("controllers.articles.create.notice")
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @article.update(article_params)
+ redirect_to team_article_path(current_team, @article), notice: t("controllers.articles.update.notice")
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @article.destroy!
+ redirect_to team_articles_path(current_team), notice: t("controllers.articles.destroy.notice")
+ end
+
+ private
+
+ def set_article
+ @article = current_team.articles.find(params[:id])
+ end
+
+ def article_params
+ params.require(:article).permit(:title, :body)
+ end
+end
diff --git a/app/controllers/avo/categories_controller.rb b/app/controllers/avo/categories_controller.rb
deleted file mode 100644
index debee0f..0000000
--- a/app/controllers/avo/categories_controller.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-class Avo::CategoriesController < Avo::ResourcesController
- # Override to handle slug changes properly
- def update
- super
- rescue ActiveRecord::RecordNotFound
- # If the record wasn't found, redirect to index
- redirect_to avo.resources_categories_path, alert: "Category not found"
- end
-
- private
-
- # Override the redirect path after update to use the new slug
- def after_update_path
- return params[:referrer] if params[:referrer].present?
-
- # Use the updated record's current slug for the redirect
- # @record should be the updated category at this point
- if @record
- avo.resources_category_path(id: @record.slug || @record.id)
- else
- avo.resources_categories_path
- end
- end
-end
diff --git a/app/controllers/avo/comments_controller.rb b/app/controllers/avo/comments_controller.rb
deleted file mode 100644
index ec054a5..0000000
--- a/app/controllers/avo/comments_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Avo::CommentsController < Avo::ResourcesController
-end
diff --git a/app/controllers/avo/posts_controller.rb b/app/controllers/avo/posts_controller.rb
deleted file mode 100644
index b428701..0000000
--- a/app/controllers/avo/posts_controller.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class Avo::PostsController < Avo::ResourcesController
- # Override to handle slug changes properly
- def update
- super
- rescue ActiveRecord::RecordNotFound
- # If the record wasn't found, redirect to index
- redirect_to avo.resources_posts_path, alert: "Post not found"
- end
-
- private
-
- # Override the redirect path after update to use the new slug
- def after_update_path
- return params[:referrer] if params[:referrer].present?
-
- # Use the updated record's current slug for the redirect
- if @record
- avo.resources_post_path(id: @record.slug || @record.id)
- else
- avo.resources_posts_path
- end
- end
-end
diff --git a/app/controllers/avo/projects_controller.rb b/app/controllers/avo/projects_controller.rb
deleted file mode 100644
index cebb186..0000000
--- a/app/controllers/avo/projects_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Avo::ProjectsController < Avo::ResourcesController
-end
diff --git a/app/controllers/avo/reports_controller.rb b/app/controllers/avo/reports_controller.rb
deleted file mode 100644
index c0c1cb6..0000000
--- a/app/controllers/avo/reports_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Avo::ReportsController < Avo::ResourcesController
-end
diff --git a/app/controllers/avo/star_snapshots_controller.rb b/app/controllers/avo/star_snapshots_controller.rb
deleted file mode 100644
index b296f6a..0000000
--- a/app/controllers/avo/star_snapshots_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Avo::StarSnapshotsController < Avo::ResourcesController
-end
diff --git a/app/controllers/avo/tags_controller.rb b/app/controllers/avo/tags_controller.rb
deleted file mode 100644
index 7ed1ec3..0000000
--- a/app/controllers/avo/tags_controller.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class Avo::TagsController < Avo::ResourcesController
- # Override to handle slug changes properly
- def update
- super
- rescue ActiveRecord::RecordNotFound
- # If the record wasn't found, redirect to index
- redirect_to avo.resources_tags_path, alert: "Tag not found"
- end
-
- private
-
- # Override the redirect path after update to use the new slug
- def after_update_path
- return params[:referrer] if params[:referrer].present?
-
- # Use the updated record's current slug for the redirect
- if @record
- avo.resources_tag_path(id: @record.slug || @record.id)
- else
- avo.resources_tags_path
- end
- end
-end
diff --git a/app/controllers/avo/testimonials_controller.rb b/app/controllers/avo/testimonials_controller.rb
deleted file mode 100644
index 6a367a9..0000000
--- a/app/controllers/avo/testimonials_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Avo::TestimonialsController < Avo::ResourcesController
-end
diff --git a/app/controllers/avo/users_controller.rb b/app/controllers/avo/users_controller.rb
deleted file mode 100644
index cc2b2f3..0000000
--- a/app/controllers/avo/users_controller.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class Avo::UsersController < Avo::ResourcesController
- # Override to handle slug changes properly
- def update
- super
- rescue ActiveRecord::RecordNotFound
- # If the record wasn't found, redirect to index
- redirect_to avo.resources_users_path, alert: "User not found"
- end
-
- private
-
- # Override the redirect path after update to use the new slug
- def after_update_path
- return params[:referrer] if params[:referrer].present?
-
- # Use the updated record's current slug for the redirect
- if @record
- avo.resources_user_path(id: @record.slug || @record.id)
- else
- avo.resources_users_path
- end
- end
-end
diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb
new file mode 100644
index 0000000..d1aabf0
--- /dev/null
+++ b/app/controllers/chats_controller.rb
@@ -0,0 +1,51 @@
+class ChatsController < ApplicationController
+ include Attachable
+
+ before_action :authenticate_user!
+ before_action :require_chats_enabled!, only: [ :index, :new, :create ]
+ before_action :set_chat, only: [ :show ]
+
+ # Prevent chat spam
+ rate_limit to: 10, within: 1.minute, name: "chats/create", only: :create
+
+ def index
+ @chats = current_user.chats.where(team: current_team).includes(:model, :messages).recent
+ end
+
+ def new
+ @chat = current_user.chats.build(team: current_team)
+ @selected_model = params[:model]
+ end
+
+ def create
+ return unless prompt.present? || attachments.present?
+
+ @chat = current_user.chats.create!(model: model, team: current_team)
+ attachment_paths = store_attachments_temporarily(attachments)
+ ChatResponseJob.perform_later(@chat.id, prompt, attachment_paths)
+
+ redirect_to team_chat_path(current_team, @chat)
+ end
+
+ def show
+ @message = @chat.messages.build
+ end
+
+ private
+
+ def set_chat
+ @chat = current_user.chats.where(team: current_team).includes(messages: [ :tool_calls, { attachments_attachments: :blob } ]).find(params[:id])
+ end
+
+ def model
+ params[:chat][:model].presence
+ end
+
+ def prompt
+ params[:chat][:prompt]
+ end
+
+ def attachments
+ params.dig(:chat, :attachments)
+ end
+end
diff --git a/app/controllers/concerns/attachable.rb b/app/controllers/concerns/attachable.rb
new file mode 100644
index 0000000..42fa920
--- /dev/null
+++ b/app/controllers/concerns/attachable.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Shared attachment handling for controllers that accept file uploads
+module Attachable
+ extend ActiveSupport::Concern
+
+ private
+
+ def store_attachments_temporarily(attachments)
+ return [] unless attachments.present?
+
+ attachments.reject(&:blank?).map do |attachment|
+ temp_dir = Rails.root.join("tmp", "uploads", SecureRandom.uuid)
+ FileUtils.mkdir_p(temp_dir)
+ safe_filename = File.basename(attachment.original_filename).gsub(/[^\w.\-]/, "_")
+ temp_path = temp_dir.join(safe_filename)
+ File.binwrite(temp_path, attachment.read)
+ temp_path.to_s
+ end
+ end
+end
diff --git a/app/controllers/madmin/active_storage/attachments_controller.rb b/app/controllers/madmin/active_storage/attachments_controller.rb
new file mode 100644
index 0000000..d950ae6
--- /dev/null
+++ b/app/controllers/madmin/active_storage/attachments_controller.rb
@@ -0,0 +1,4 @@
+module Madmin
+ class ActiveStorage::AttachmentsController < Madmin::ResourceController
+ end
+end
diff --git a/app/controllers/madmin/active_storage/blobs_controller.rb b/app/controllers/madmin/active_storage/blobs_controller.rb
new file mode 100644
index 0000000..fb59018
--- /dev/null
+++ b/app/controllers/madmin/active_storage/blobs_controller.rb
@@ -0,0 +1,8 @@
+module Madmin
+ class ActiveStorage::BlobsController < Madmin::ResourceController
+ def new
+ super
+ @record.assign_attributes(filename: "")
+ end
+ end
+end
diff --git a/app/controllers/madmin/active_storage/variant_records_controller.rb b/app/controllers/madmin/active_storage/variant_records_controller.rb
new file mode 100644
index 0000000..8798628
--- /dev/null
+++ b/app/controllers/madmin/active_storage/variant_records_controller.rb
@@ -0,0 +1,4 @@
+module Madmin
+ class ActiveStorage::VariantRecordsController < Madmin::ResourceController
+ end
+end
diff --git a/app/controllers/madmin/admins_controller.rb b/app/controllers/madmin/admins_controller.rb
new file mode 100644
index 0000000..34acf90
--- /dev/null
+++ b/app/controllers/madmin/admins_controller.rb
@@ -0,0 +1,9 @@
+module Madmin
+ class AdminsController < Madmin::ResourceController
+ def send_magic_link
+ @record = Admin.find(params[:id])
+ AdminMailer.magic_link(@record).deliver_later
+ redirect_to madmin_admin_path(@record), notice: "Magic link sent to #{@record.email}!"
+ end
+ end
+end
diff --git a/app/controllers/madmin/application_controller.rb b/app/controllers/madmin/application_controller.rb
new file mode 100644
index 0000000..7e20183
--- /dev/null
+++ b/app/controllers/madmin/application_controller.rb
@@ -0,0 +1,19 @@
+module Madmin
+ class ApplicationController < Madmin::BaseController
+ before_action :authenticate_admin!
+ helper Madmin::ApplicationHelper
+
+ private
+
+ def authenticate_admin!
+ admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id]
+ redirect_to main_app.new_admins_session_path, alert: "Please log in as admin" unless admin
+ end
+
+ helper_method :current_admin
+
+ def current_admin
+ @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id]
+ end
+ end
+end
diff --git a/app/controllers/madmin/categories_controller.rb b/app/controllers/madmin/categories_controller.rb
new file mode 100644
index 0000000..01ce320
--- /dev/null
+++ b/app/controllers/madmin/categories_controller.rb
@@ -0,0 +1,9 @@
+module Madmin
+ class CategoriesController < Madmin::ResourceController
+ private
+
+ def scoped_resources
+ super.includes(:posts)
+ end
+ end
+end
diff --git a/app/controllers/madmin/chats_controller.rb b/app/controllers/madmin/chats_controller.rb
new file mode 100644
index 0000000..5ab2651
--- /dev/null
+++ b/app/controllers/madmin/chats_controller.rb
@@ -0,0 +1,31 @@
+module Madmin
+ class ChatsController < Madmin::ResourceController
+ skip_before_action :set_record, only: :toggle_public_chats
+
+ def toggle_public_chats
+ setting = Setting.instance
+ setting.update!(public_chats: !setting.public_chats?)
+ redirect_to main_app.madmin_chats_path, notice: "Public chats #{setting.public_chats? ? 'enabled' : 'disabled'}"
+ end
+
+ private
+
+ def scoped_resources
+ resources = super.includes(:user, :model, :messages)
+
+ if params[:created_at_from].present? && params[:created_at_to].present?
+ resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to])
+ elsif params[:created_at].present?
+ date = Date.parse(params[:created_at])
+ resources = resources.where("DATE(created_at) = ?", date)
+ end
+
+ # Custom search by user email
+ if params[:q].present?
+ resources = resources.joins(:user).where("users.email LIKE ?", "%#{params[:q]}%")
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/comments_controller.rb b/app/controllers/madmin/comments_controller.rb
new file mode 100644
index 0000000..d753817
--- /dev/null
+++ b/app/controllers/madmin/comments_controller.rb
@@ -0,0 +1,15 @@
+module Madmin
+ class CommentsController < Madmin::ResourceController
+ private
+
+ def scoped_resources
+ resources = super.includes(:user, :post)
+
+ if params[:published].present?
+ resources = resources.where(published: params[:published] == "true")
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/dashboard_controller.rb b/app/controllers/madmin/dashboard_controller.rb
new file mode 100644
index 0000000..443168d
--- /dev/null
+++ b/app/controllers/madmin/dashboard_controller.rb
@@ -0,0 +1,84 @@
+module Madmin
+ class DashboardController < Madmin::ApplicationController
+ def show
+ @metrics = {
+ total_users: User.count,
+ total_admins: Admin.count,
+ total_teams: Team.count,
+ total_chats: Chat.count,
+ total_messages: Message.count,
+ total_tokens: calculate_total_tokens,
+ total_cost: Message.sum(:cost),
+ total_tool_calls: ToolCall.count,
+ recent_chats: Chat.where("created_at >= ?", 7.days.ago).count,
+ recent_messages: Message.where("created_at >= ?", 7.days.ago).count,
+ recent_users: User.where("created_at >= ?", 7.days.ago).count,
+ recent_teams: Team.where("created_at >= ?", 7.days.ago).count,
+ total_models: Model.enabled.count,
+ total_projects: Project.count,
+ total_testimonials: Testimonial.count,
+ published_testimonials: Testimonial.published.count
+ }
+
+ @subscription_stats = {
+ active: Team.where(subscription_status: "active").count,
+ trialing: Team.where(subscription_status: "trialing").count,
+ past_due: Team.where(subscription_status: "past_due").count,
+ canceled: Team.where(subscription_status: "canceled").count,
+ none: Team.where(subscription_status: [ nil, "" ]).count
+ }
+
+ @subscription_revenue = calculate_subscription_revenue
+
+ @recent_chats = Chat.includes(:user, :model, :messages).order(created_at: :desc).limit(5)
+ @recent_users = User.includes(:memberships).order(created_at: :desc).limit(5)
+ @recent_teams = Team.includes(:memberships, :chats).order(created_at: :desc).limit(5)
+
+ @activity_chart_data = build_activity_chart_data
+ end
+
+ private
+
+ def calculate_total_tokens
+ Message.sum("COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0) + COALESCE(cached_tokens, 0) + COALESCE(cache_creation_tokens, 0)")
+ end
+
+ def calculate_subscription_revenue
+ Rails.cache.fetch("admin_subscription_revenue", expires_in: 15.minutes) do
+ fetch_stripe_revenue
+ end
+ end
+
+ def fetch_stripe_revenue
+ return { mrr: 0, total: 0, available: false } unless Setting.instance.stripe_secret_key.present?
+
+ subs = Stripe::Subscription.list(status: "active", limit: 100)
+ mrr = subs.data.sum do |s|
+ s.items.data.sum do |item|
+ amount = item.price.unit_amount.to_f
+ item.price.recurring&.interval == "year" ? amount / 12.0 : amount
+ end
+ end / 100.0
+
+ invoices = Stripe::Invoice.list(status: "paid", limit: 100)
+ total = invoices.data.sum(&:amount_paid) / 100.0
+
+ { mrr: mrr, total: total, available: true }
+ rescue => e
+ Rails.logger.warn("Failed to fetch Stripe revenue: #{e.message}")
+ { mrr: 0, total: 0, available: false }
+ end
+
+ def build_activity_chart_data
+ dates = (6.days.ago.to_date..Date.current).to_a
+
+ cost_sums = Message.where(created_at: dates.first.all_day.first..dates.last.end_of_day)
+ .group("date(created_at)").sum(:cost)
+
+ {
+ labels: dates.map { |d| d.strftime("%b %d") },
+ cost: dates.map { |d| (cost_sums[d.to_s] || 0).to_f }
+ }
+ end
+ end
+end
diff --git a/app/controllers/madmin/languages_controller.rb b/app/controllers/madmin/languages_controller.rb
new file mode 100644
index 0000000..69a1be7
--- /dev/null
+++ b/app/controllers/madmin/languages_controller.rb
@@ -0,0 +1,20 @@
+module Madmin
+ class LanguagesController < Madmin::ResourceController
+ skip_before_action :set_record, only: :sync
+
+ def sync
+ result = Language.sync_from_locale_files!
+ parts = []
+ parts << "Added #{result[:added].join(', ')}" if result[:added].any?
+ parts << "Removed #{result[:removed].join(', ')}" if result[:removed].any?
+ notice = parts.any? ? parts.join(". ") : "All locale files already synced"
+ redirect_to main_app.madmin_languages_path, notice: notice
+ end
+
+ def toggle
+ language = Language.find(params[:id])
+ language.update!(enabled: !language.enabled?)
+ redirect_to main_app.madmin_languages_path, notice: "#{language.name} #{language.enabled? ? 'enabled' : 'disabled'}"
+ end
+ end
+end
diff --git a/app/controllers/madmin/mail_controller.rb b/app/controllers/madmin/mail_controller.rb
new file mode 100644
index 0000000..14a0ab2
--- /dev/null
+++ b/app/controllers/madmin/mail_controller.rb
@@ -0,0 +1,27 @@
+module Madmin
+ class MailController < Madmin::ApplicationController
+ def show
+ @setting = Setting.instance
+ end
+
+ def edit
+ @setting = Setting.instance
+ end
+
+ def update
+ @setting = Setting.instance
+
+ if @setting.update(mail_params)
+ redirect_to main_app.madmin_mail_path, notice: t("controllers.madmin.mail.update.notice")
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def mail_params
+ params.require(:setting).permit(:mail_from, :smtp_address, :smtp_username, :smtp_password)
+ end
+ end
+end
diff --git a/app/controllers/madmin/messages_controller.rb b/app/controllers/madmin/messages_controller.rb
new file mode 100644
index 0000000..eb5eb92
--- /dev/null
+++ b/app/controllers/madmin/messages_controller.rb
@@ -0,0 +1,20 @@
+module Madmin
+ class MessagesController < Madmin::ResourceController
+ def scoped_resources
+ resources = super.includes(:chat, :model, :tool_calls)
+
+ # Role filter
+ resources = resources.where(role: params[:role]) if params[:role].present?
+
+ # Date filter
+ if params[:created_at_from].present? && params[:created_at_to].present?
+ resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to])
+ elsif params[:created_at].present?
+ date = Date.parse(params[:created_at])
+ resources = resources.where("DATE(created_at) = ?", date)
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/models_controller.rb b/app/controllers/madmin/models_controller.rb
new file mode 100644
index 0000000..ae41f1e
--- /dev/null
+++ b/app/controllers/madmin/models_controller.rb
@@ -0,0 +1,16 @@
+module Madmin
+ class ModelsController < Madmin::ResourceController
+ skip_before_action :set_record, only: [ :refresh_all ]
+
+ def scoped_resources
+ resources = super.enabled
+ resources = resources.where(provider: params[:provider]) if params[:provider].present?
+ resources
+ end
+
+ def refresh_all
+ Model.refresh!
+ redirect_to resource.index_path, notice: "Models refreshed! Total: #{Model.count}"
+ end
+ end
+end
diff --git a/app/controllers/madmin/posts_controller.rb b/app/controllers/madmin/posts_controller.rb
new file mode 100644
index 0000000..9e2d798
--- /dev/null
+++ b/app/controllers/madmin/posts_controller.rb
@@ -0,0 +1,23 @@
+module Madmin
+ class PostsController < Madmin::ResourceController
+ private
+
+ def scoped_resources
+ resources = super.includes(:user, :category).unscope(:order)
+
+ if params[:post_type].present?
+ resources = resources.where(post_type: params[:post_type])
+ end
+
+ if params[:published].present?
+ resources = resources.where(published: params[:published] == "true")
+ end
+
+ if params[:needs_review] == "true"
+ resources = resources.where(needs_admin_review: true)
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/prices_controller.rb b/app/controllers/madmin/prices_controller.rb
new file mode 100644
index 0000000..3e758e9
--- /dev/null
+++ b/app/controllers/madmin/prices_controller.rb
@@ -0,0 +1,15 @@
+module Madmin
+ class PricesController < Madmin::ApplicationController
+ def show
+ @setting = Setting.instance
+ @prices = @setting.stripe_secret_key.present? ? Price.all : []
+ rescue Stripe::AuthenticationError, Stripe::APIConnectionError
+ @prices = []
+ end
+
+ def sync
+ Price.clear_cache
+ redirect_to main_app.madmin_prices_path, notice: t("controllers.madmin.prices.sync.notice")
+ end
+ end
+end
diff --git a/app/controllers/madmin/projects_controller.rb b/app/controllers/madmin/projects_controller.rb
new file mode 100644
index 0000000..00a0f1d
--- /dev/null
+++ b/app/controllers/madmin/projects_controller.rb
@@ -0,0 +1,19 @@
+module Madmin
+ class ProjectsController < Madmin::ResourceController
+ private
+
+ def scoped_resources
+ resources = super.includes(:user)
+
+ if params[:hidden] == "true"
+ resources = resources.where(hidden: true)
+ end
+
+ if params[:archived] == "true"
+ resources = resources.where(archived: true)
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/providers_controller.rb b/app/controllers/madmin/providers_controller.rb
new file mode 100644
index 0000000..f34b626
--- /dev/null
+++ b/app/controllers/madmin/providers_controller.rb
@@ -0,0 +1,18 @@
+module Madmin
+ class ProvidersController < Madmin::ApplicationController
+ def index
+ @providers = ProviderCredential.provider_settings
+ @credentials = ProviderCredential.all.index_by { |c| [ c.provider, c.key ] }
+ end
+
+ def update
+ params[:providers]&.each do |provider, settings|
+ settings.each do |key, value|
+ ProviderCredential.set(provider, key, value)
+ end
+ end
+
+ redirect_to main_app.madmin_providers_path, notice: t("controllers.madmin.providers.update.notice")
+ end
+ end
+end
diff --git a/app/controllers/madmin/reports_controller.rb b/app/controllers/madmin/reports_controller.rb
new file mode 100644
index 0000000..1227205
--- /dev/null
+++ b/app/controllers/madmin/reports_controller.rb
@@ -0,0 +1,15 @@
+module Madmin
+ class ReportsController < Madmin::ResourceController
+ private
+
+ def scoped_resources
+ resources = super.includes(:user, :post)
+
+ if params[:reason].present?
+ resources = resources.where(reason: params[:reason])
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/settings/ai_models_controller.rb b/app/controllers/madmin/settings/ai_models_controller.rb
new file mode 100644
index 0000000..3088641
--- /dev/null
+++ b/app/controllers/madmin/settings/ai_models_controller.rb
@@ -0,0 +1,35 @@
+module Madmin
+ module Settings
+ class AiModelsController < Madmin::ApplicationController
+ def show
+ @setting = Setting.instance
+ end
+
+ def edit
+ @setting = Setting.instance
+ end
+
+ def update
+ @setting = Setting.instance
+
+ if @setting.update(setting_params)
+ redirect_to main_app.madmin_settings_ai_models_path, notice: t("controllers.madmin.settings.ai_models.update.notice")
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def setting_params
+ params.require(:setting).permit(
+ :default_ai_model,
+ :summary_model,
+ :testimonial_model,
+ :translation_model,
+ :validation_model,
+ )
+ end
+ end
+ end
+end
diff --git a/app/controllers/madmin/settings_controller.rb b/app/controllers/madmin/settings_controller.rb
new file mode 100644
index 0000000..981c87d
--- /dev/null
+++ b/app/controllers/madmin/settings_controller.rb
@@ -0,0 +1,41 @@
+module Madmin
+ class SettingsController < Madmin::ApplicationController
+ def show
+ @setting = Setting.instance
+ end
+
+ def edit
+ @setting = Setting.instance
+ end
+
+ def update
+ @setting = Setting.instance
+
+ if @setting.update(setting_params)
+ redirect_to main_app.madmin_settings_path, notice: t("controllers.madmin.settings.update.notice")
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def setting_params
+ params.require(:setting).permit(
+ :github_api_token,
+ :github_rubycommunity_client_id,
+ :github_rubycommunity_client_secret,
+ :github_whyruby_client_id,
+ :github_whyruby_client_secret,
+ :litestream_replica_bucket,
+ :litestream_replica_key_id,
+ :litestream_replica_access_key,
+ :public_chats,
+ :stripe_secret_key,
+ :stripe_publishable_key,
+ :stripe_webhook_secret,
+ :trial_days,
+ )
+ end
+ end
+end
diff --git a/app/controllers/madmin/tags_controller.rb b/app/controllers/madmin/tags_controller.rb
new file mode 100644
index 0000000..cfd28a4
--- /dev/null
+++ b/app/controllers/madmin/tags_controller.rb
@@ -0,0 +1,4 @@
+module Madmin
+ class TagsController < Madmin::ResourceController
+ end
+end
diff --git a/app/controllers/madmin/teams_controller.rb b/app/controllers/madmin/teams_controller.rb
new file mode 100644
index 0000000..7c94bb4
--- /dev/null
+++ b/app/controllers/madmin/teams_controller.rb
@@ -0,0 +1,45 @@
+module Madmin
+ class TeamsController < Madmin::ResourceController
+ private
+
+ def set_record
+ @record = resource.model
+ .includes(memberships: :user, chats: [ :model, :messages ])
+ .find_by!(slug: params[:id])
+ end
+
+ def scoped_resources
+ resources = resource.model.send(valid_scope)
+ resources = Madmin::Search.new(resources, resource, search_term).run
+ resources = resources.includes(memberships: :user, chats: [])
+
+ dir = sort_direction == "asc" ? "ASC" : "DESC"
+
+ case sort_column
+ when "owner_name"
+ resources
+ .left_joins(memberships: :user)
+ .where(memberships: { role: "owner" })
+ .or(resources.left_joins(memberships: :user).where(memberships: { id: nil }))
+ .reorder(Arel.sql("users.name #{dir}"))
+ when "members_count"
+ resources
+ .left_joins(:memberships)
+ .group("teams.id")
+ .reorder(Arel.sql("COUNT(memberships.id) #{dir}"))
+ when "chats_count"
+ resources
+ .left_joins(:chats)
+ .group("teams.id")
+ .reorder(Arel.sql("COUNT(chats.id) #{dir}"))
+ when "total_cost"
+ resources
+ .left_joins(:chats)
+ .group("teams.id")
+ .reorder(Arel.sql("COALESCE(SUM(chats.total_cost), 0) #{dir}"))
+ else
+ resources.reorder(sort_column => sort_direction)
+ end
+ end
+ end
+end
diff --git a/app/controllers/madmin/testimonials_controller.rb b/app/controllers/madmin/testimonials_controller.rb
new file mode 100644
index 0000000..1e9ec36
--- /dev/null
+++ b/app/controllers/madmin/testimonials_controller.rb
@@ -0,0 +1,15 @@
+module Madmin
+ class TestimonialsController < Madmin::ResourceController
+ private
+
+ def scoped_resources
+ resources = super.includes(:user)
+
+ if params[:published].present?
+ resources = resources.where(published: params[:published] == "true")
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/tool_calls_controller.rb b/app/controllers/madmin/tool_calls_controller.rb
new file mode 100644
index 0000000..3aa2e01
--- /dev/null
+++ b/app/controllers/madmin/tool_calls_controller.rb
@@ -0,0 +1,16 @@
+module Madmin
+ class ToolCallsController < Madmin::ResourceController
+ def scoped_resources
+ resources = super.includes(:message)
+
+ if params[:created_at_from].present? && params[:created_at_to].present?
+ resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to])
+ elsif params[:created_at].present?
+ date = Date.parse(params[:created_at])
+ resources = resources.where("DATE(created_at) = ?", date)
+ end
+
+ resources
+ end
+ end
+end
diff --git a/app/controllers/madmin/users_controller.rb b/app/controllers/madmin/users_controller.rb
new file mode 100644
index 0000000..f2bcc3b
--- /dev/null
+++ b/app/controllers/madmin/users_controller.rb
@@ -0,0 +1,46 @@
+module Madmin
+ class UsersController < Madmin::ResourceController
+ def index
+ super
+ companies = @records.map(&:company).compact.reject(&:blank?).uniq
+ @company_teams = Team.where(name: companies).index_by(&:name)
+ end
+
+ private
+
+ def set_record
+ @record = resource.model
+ .includes(:posts, :comments, :testimonial, :projects)
+ .find(params[:id])
+ end
+
+ def scoped_resources
+ resources = resource.model.send(valid_scope)
+ resources = Madmin::Search.new(resources, resource, search_term).run
+
+ if params[:created_at_from].present? && params[:created_at_to].present?
+ resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to])
+ elsif params[:created_at].present?
+ date = Date.parse(params[:created_at])
+ resources = resources.where("DATE(created_at) = ?", date)
+ end
+
+ if params[:role].present?
+ resources = resources.where(role: params[:role])
+ end
+
+ if params[:trusted] == "true"
+ resources = resources.trusted
+ end
+
+ dir = sort_direction == "asc" ? "ASC" : "DESC"
+
+ case sort_column
+ when "published_posts_count", "published_comments_count", "github_stars_sum"
+ resources.reorder(Arel.sql("#{sort_column} #{dir}"))
+ else
+ resources.reorder(sort_column => sort_direction)
+ end
+ end
+ end
+end
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
new file mode 100644
index 0000000..c0f7839
--- /dev/null
+++ b/app/controllers/messages_controller.rb
@@ -0,0 +1,36 @@
+class MessagesController < ApplicationController
+ include Attachable
+
+ before_action :authenticate_user!
+ before_action :set_chat
+
+ # AI calls are expensive - strict limits
+ rate_limit to: 20, within: 1.minute, name: "messages/short", only: :create
+ rate_limit to: 100, within: 1.hour, name: "messages/long", only: :create
+
+ def create
+ return unless content.present? || attachments.present?
+
+ attachment_paths = store_attachments_temporarily(attachments)
+ ChatResponseJob.perform_later(@chat.id, content, attachment_paths)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to team_chat_path(current_team, @chat) }
+ end
+ end
+
+ private
+
+ def set_chat
+ @chat = current_user.chats.where(team: current_team).find(params[:chat_id])
+ end
+
+ def content
+ params.dig(:message, :content) || ""
+ end
+
+ def attachments
+ params.dig(:message, :attachments)
+ end
+end
diff --git a/app/controllers/models/refreshes_controller.rb b/app/controllers/models/refreshes_controller.rb
new file mode 100644
index 0000000..b60d5cf
--- /dev/null
+++ b/app/controllers/models/refreshes_controller.rb
@@ -0,0 +1,8 @@
+class Models::RefreshesController < ApplicationController
+ before_action :authenticate_admin!
+
+ def create
+ Model.refresh!
+ redirect_to team_models_path(current_team), notice: t("controllers.models.refreshes.create.notice")
+ end
+end
diff --git a/app/controllers/models_controller.rb b/app/controllers/models_controller.rb
new file mode 100644
index 0000000..54a8f73
--- /dev/null
+++ b/app/controllers/models_controller.rb
@@ -0,0 +1,11 @@
+class ModelsController < ApplicationController
+ before_action :authenticate_user!
+
+ def index
+ @models = Model.all
+ end
+
+ def show
+ @model = Model.find(params[:id])
+ end
+end
diff --git a/app/controllers/og_images_controller.rb b/app/controllers/og_images_controller.rb
new file mode 100644
index 0000000..880d99d
--- /dev/null
+++ b/app/controllers/og_images_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Renders an HTML page at 1200x630 for screenshotting into a static OG image.
+# Visit /og-image in a browser, resize to 1200x630, and screenshot.
+# Save as public/og-image.png for use in meta tags.
+class OgImagesController < ApplicationController
+ layout false
+
+ def show
+ @app_name = t("app_name")
+ @tagline = t("og_image.tagline")
+ @domain = request.host
+ end
+end
diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb
new file mode 100644
index 0000000..ff3cce6
--- /dev/null
+++ b/app/controllers/onboardings_controller.rb
@@ -0,0 +1,35 @@
+class OnboardingsController < ApplicationController
+ skip_before_action :require_onboarding!, raise: false
+ before_action :authenticate_user!
+ before_action :redirect_if_onboarded
+ layout "onboarding"
+
+ def show
+ @user = current_user
+ @team = current_user.teams.first
+ end
+
+ def update
+ @user = current_user
+ @team = current_user.teams.first
+
+ ActiveRecord::Base.transaction do
+ @user.update!(name: onboarding_params[:name])
+ @team.update!(name: onboarding_params[:team_name]) if @team && @user.owner_of?(@team) && onboarding_params[:team_name].present?
+ end
+
+ redirect_to team_root_path(@team), notice: t("controllers.onboardings.update.notice", name: @user.name)
+ rescue ActiveRecord::RecordInvalid
+ render :show, status: :unprocessable_entity
+ end
+
+ private
+
+ def onboarding_params
+ params.require(:onboarding).permit(:name, :team_name)
+ end
+
+ def redirect_if_onboarded
+ redirect_to root_path if current_user.onboarded?
+ end
+end
diff --git a/app/controllers/posts/duplicate_checks_controller.rb b/app/controllers/posts/duplicate_checks_controller.rb
new file mode 100644
index 0000000..cd156db
--- /dev/null
+++ b/app/controllers/posts/duplicate_checks_controller.rb
@@ -0,0 +1,45 @@
+class Posts::DuplicateChecksController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ url = params[:url]
+ normalized_url = normalize_url_for_checking(url)
+ exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id]
+
+ existing_post = Post.where(url: normalized_url)
+ existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present?
+ existing_post = existing_post.first
+
+ if existing_post
+ render json: {
+ duplicate: true,
+ existing_post: {
+ id: existing_post.id,
+ title: existing_post.title,
+ url: post_path_for(existing_post)
+ }
+ }
+ else
+ render json: { duplicate: false }
+ end
+ end
+
+ private
+
+ def normalize_url_for_checking(url)
+ return nil unless url.present?
+
+ normalized = url.strip.gsub(/\/+$/, "")
+
+ if normalized.match?(/^http:\/\/(www\.)?(github\.com|twitter\.com|youtube\.com|linkedin\.com|stackoverflow\.com)/i)
+ normalized = normalized.sub(/^http:/, "https:")
+ end
+
+ normalized
+ end
+
+ def post_path_for(post)
+ return root_path unless post.category
+ post_path(post.category, post)
+ end
+end
diff --git a/app/controllers/posts/images_controller.rb b/app/controllers/posts/images_controller.rb
new file mode 100644
index 0000000..7c5230a
--- /dev/null
+++ b/app/controllers/posts/images_controller.rb
@@ -0,0 +1,34 @@
+class Posts::ImagesController < ApplicationController
+ before_action :set_post
+
+ def show
+ if @post.featured_image.attached?
+ og_blob = @post.image_variant(:og)
+
+ if og_blob
+ send_data og_blob.download,
+ type: "image/webp",
+ disposition: "inline"
+ else
+ send_data @post.featured_image.download,
+ type: "image/webp",
+ disposition: "inline"
+ end
+ else
+ send_file Rails.root.join("public", "og-image.webp"),
+ type: "image/webp",
+ disposition: "inline"
+ end
+ end
+
+ private
+
+ def set_post
+ if params[:category_id]
+ @category = Category.friendly.find(params[:category_id])
+ @post = @category.posts.friendly.find(params[:post_id] || params[:id])
+ else
+ @post = Post.friendly.find(params[:post_id] || params[:id])
+ end
+ end
+end
diff --git a/app/controllers/posts/metadata_controller.rb b/app/controllers/posts/metadata_controller.rb
new file mode 100644
index 0000000..e118103
--- /dev/null
+++ b/app/controllers/posts/metadata_controller.rb
@@ -0,0 +1,61 @@
+class Posts::MetadataController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ url = params[:url]
+ exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id]
+
+ normalized_url = normalize_url_for_checking(url)
+
+ existing_post = Post.where(url: normalized_url)
+ existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present?
+ existing_post = existing_post.first
+
+ if existing_post
+ render json: {
+ success: false,
+ duplicate: true,
+ existing_post: {
+ id: existing_post.id,
+ title: existing_post.title,
+ url: post_path_for(existing_post)
+ }
+ }
+ return
+ end
+
+ begin
+ post = Post.new(url: url)
+ result = post.fetch_metadata!
+
+ metadata = {
+ title: result[:title],
+ summary: result[:description],
+ image_url: result[:image_url]
+ }
+
+ render json: { success: true, metadata: metadata }
+ rescue => e
+ render json: { success: false, error: e.message }
+ end
+ end
+
+ private
+
+ def normalize_url_for_checking(url)
+ return nil unless url.present?
+
+ normalized = url.strip.gsub(/\/+$/, "")
+
+ if normalized.match?(/^http:\/\/(www\.)?(github\.com|twitter\.com|youtube\.com|linkedin\.com|stackoverflow\.com)/i)
+ normalized = normalized.sub(/^http:/, "https:")
+ end
+
+ normalized
+ end
+
+ def post_path_for(post)
+ return root_path unless post.category
+ post_path(post.category, post)
+ end
+end
diff --git a/app/controllers/posts/previews_controller.rb b/app/controllers/posts/previews_controller.rb
new file mode 100644
index 0000000..d3d3ad1
--- /dev/null
+++ b/app/controllers/posts/previews_controller.rb
@@ -0,0 +1,8 @@
+class Posts::PreviewsController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ html = helpers.markdown_to_html(params[:content])
+ render json: { html: html }
+ end
+end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index d02d9cb..45bc621 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -1,37 +1,12 @@
class PostsController < ApplicationController
- before_action :authenticate_user!, except: [ :show, :image ]
- before_action :set_post, only: [ :show, :edit, :update, :destroy, :image ]
+ before_action :authenticate_user!, except: [ :show ]
+ before_action :set_post, only: [ :show, :edit, :update, :destroy ]
before_action :authorize_user!, only: [ :edit, :update, :destroy ]
def show
@comments = @post.comments.published.includes(:user).order(created_at: :asc)
end
- # Serve images directly for posts with stable URLs for social media
- def image
- if @post.featured_image.attached?
- # Serve the og variant (1200x630 WebP) for social media
- og_blob = @post.image_variant(:og)
-
- if og_blob
- send_data og_blob.download,
- type: "image/webp",
- disposition: "inline"
- else
- # Fallback to original if og variant not available yet
- send_data @post.featured_image.download,
- type: "image/webp",
- disposition: "inline"
- end
- else
- # Serve default OG image (should also be WebP)
- send_file Rails.root.join("public", "og-image.webp"),
- type: "image/webp",
- disposition: "inline"
- end
- end
-
-
def new
@post = current_user.posts.build
@@ -124,84 +99,11 @@ def destroy
end
end
- def preview
- html = helpers.markdown_to_html(params[:content])
- render json: { html: html }
- end
-
- def fetch_metadata
- url = params[:url]
- exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id]
-
- # Normalize URL for duplicate checking
- normalized_url = normalize_url_for_checking(url)
-
- # Check for existing post (excluding current post if editing)
- existing_post = Post.where(url: normalized_url)
- existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present?
- existing_post = existing_post.first
-
- if existing_post
- render json: {
- success: false,
- duplicate: true,
- existing_post: {
- id: existing_post.id,
- title: existing_post.title,
- url: post_path_for(existing_post)
- }
- }
- return
- end
-
- begin
- fetcher = MetadataFetcher.new(url)
- result = fetcher.fetch!
-
- metadata = {
- title: result[:title],
- summary: result[:description],
- image_url: result[:image_url]
- }
-
- render json: { success: true, metadata: metadata }
- rescue => e
- render json: { success: false, error: e.message }
- end
- end
-
- def check_duplicate_url
- url = params[:url]
- normalized_url = normalize_url_for_checking(url)
-
- # Handle exclude_id from both regular params and JSON body
- exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id]
-
- existing_post = Post.where(url: normalized_url)
- existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present?
- existing_post = existing_post.first
-
- if existing_post
- render json: {
- duplicate: true,
- existing_post: {
- id: existing_post.id,
- title: existing_post.title,
- url: post_path_for(existing_post)
- }
- }
- else
- render json: { duplicate: false }
- end
- end
-
private
def fetch_and_attach_image_from_url(url)
return if url.blank?
-
- # Use ImageProcessor to fetch and process the image
- ImageProcessor.process_from_url(url, @post)
+ @post.attach_image_from_url!(url)
end
def set_post
@@ -222,20 +124,6 @@ def set_post
- def normalize_url_for_checking(url)
- return nil unless url.present?
-
- # Strip and remove trailing slashes
- normalized = url.strip.gsub(/\/+$/, "")
-
- # Convert http to https for common domains
- if normalized.match?(/^http:\/\/(www\.)?(github\.com|twitter\.com|youtube\.com|linkedin\.com|stackoverflow\.com)/i)
- normalized = normalized.sub(/^http:/, "https:")
- end
-
- normalized
- end
-
def authorize_user!
unless @post.user == current_user || current_user.admin?
redirect_to root_path, alert: "Not authorized"
@@ -272,6 +160,7 @@ def process_tags
end
def post_path_for(post)
+ return root_path unless post.category
post_path(post.category, post)
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
new file mode 100644
index 0000000..33d5dae
--- /dev/null
+++ b/app/controllers/profiles_controller.rb
@@ -0,0 +1,30 @@
+class ProfilesController < ApplicationController
+ before_action :authenticate_user!
+
+ def show
+ @user = current_user
+ @user_language = Language.find_by(code: @user.locale) if @user.locale.present?
+ end
+
+ def edit
+ @user = current_user
+ @languages = Language.enabled.by_name
+ end
+
+ def update
+ @user = current_user
+
+ if @user.update(profile_params)
+ redirect_to team_profile_path(current_team), notice: t("controllers.profiles.update.notice")
+ else
+ @languages = Language.enabled.by_name
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def profile_params
+ params.require(:user).permit(:name, :locale, :avatar, :remove_avatar)
+ end
+end
diff --git a/app/controllers/sessions/verifications_controller.rb b/app/controllers/sessions/verifications_controller.rb
new file mode 100644
index 0000000..7987447
--- /dev/null
+++ b/app/controllers/sessions/verifications_controller.rb
@@ -0,0 +1,80 @@
+class Sessions::VerificationsController < ApplicationController
+ rate_limit to: 10, within: 5.minutes, name: "sessions/verify",
+ with: -> { redirect_to root_path, alert: t("controllers.sessions.rate_limit.verify") }
+
+ def show
+ user = User.find_signed!(params[:token], purpose: :magic_link)
+
+ if params[:team].present?
+ handle_team_invitation(user, params[:team], params[:invited_by])
+ end
+
+ session[:user_id] = user.id
+ save_locale_from_header(user) if user.locale.nil?
+
+ if user.onboarded?
+ redirect_to after_login_path(user, params[:team]), notice: t("controllers.sessions.verify.notice", name: user.name)
+ else
+ ensure_team_exists(user, params[:team])
+ redirect_to onboarding_path
+ end
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
+ redirect_to root_path, alert: t("controllers.sessions.verify.alert")
+ end
+
+ private
+
+ def handle_team_invitation(user, team_slug, invited_by_id)
+ team = Team.find_by(slug: team_slug)
+ return unless team
+
+ invited_by = User.find_by(id: invited_by_id)
+
+ unless user.member_of?(team)
+ user.memberships.create!(team: team, invited_by: invited_by, role: "member")
+ end
+ end
+
+ def after_login_path(user, invited_team_slug = nil)
+ if invited_team_slug.present?
+ team = Team.find_by(slug: invited_team_slug)
+ return team_root_path(team) if team && user.member_of?(team)
+ end
+
+ teams = user.teams
+
+ case teams.size
+ when 0
+ team = create_personal_team(user)
+ team_root_path(team)
+ when 1
+ team_root_path(teams.first)
+ else
+ teams_path
+ end
+ end
+
+ def ensure_team_exists(user, invited_team_slug = nil)
+ return if user.teams.exists?
+
+ create_personal_team(user)
+ end
+
+ def create_personal_team(user)
+ Team.find_or_create_for_user!(user)
+ end
+
+ def save_locale_from_header(user)
+ return unless request.headers["Accept-Language"]
+
+ accepted = parse_accept_language(request.headers["Accept-Language"])
+ enabled = Language.enabled_codes
+
+ accepted.each do |code|
+ if enabled.include?(code)
+ user.update_column(:locale, code)
+ return
+ end
+ end
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..82e8913
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,32 @@
+class SessionsController < ApplicationController
+ # Short-term: prevent rapid-fire attempts
+ rate_limit to: 5, within: 1.minute, name: "sessions/short", only: :create,
+ with: -> { redirect_to new_session_path, alert: t("controllers.sessions.rate_limit.short") }
+
+ # Long-term: prevent sustained attacks
+ rate_limit to: 20, within: 1.hour, name: "sessions/long", only: :create,
+ with: -> { redirect_to new_session_path, alert: t("controllers.sessions.rate_limit.long") }
+
+ def new
+ redirect_to root_path if current_user
+ end
+
+ def create
+ email = params.expect(session: :email)[:email]
+ user = User.find_by(email: email)
+
+ # Create user if doesn't exist (first magic link creates the account)
+ # Name is collected during onboarding after first login
+ user ||= User.create!(email: email)
+
+ # Send magic link
+ UserMailer.magic_link(user).deliver_later
+
+ redirect_to new_session_path, notice: t("controllers.sessions.create.notice")
+ end
+
+ def destroy
+ reset_session
+ redirect_to new_session_path, notice: t("controllers.sessions.destroy.notice")
+ end
+end
diff --git a/app/controllers/tags/searches_controller.rb b/app/controllers/tags/searches_controller.rb
new file mode 100644
index 0000000..e3336da
--- /dev/null
+++ b/app/controllers/tags/searches_controller.rb
@@ -0,0 +1,15 @@
+class Tags::SearchesController < ApplicationController
+ def show
+ query = params[:q].to_s.strip.downcase
+
+ if query.present?
+ tags = Tag.where("LOWER(name) LIKE ?", "%#{query}%")
+ .order(:name)
+ .limit(10)
+
+ render json: tags.map { |tag| { id: tag.id, name: tag.name } }
+ else
+ render json: []
+ end
+ end
+end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 3477f50..4d77240 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -4,18 +4,4 @@ def show
@posts = @tag.posts.published.includes(:user, :category)
.page(params[:page])
end
-
- def search
- query = params[:q].to_s.strip.downcase
-
- if query.present?
- tags = Tag.where("LOWER(name) LIKE ?", "%#{query}%")
- .order(:name)
- .limit(10)
-
- render json: tags.map { |tag| { id: tag.id, name: tag.name } }
- else
- render json: []
- end
- end
end
diff --git a/app/controllers/teams/billing_controller.rb b/app/controllers/teams/billing_controller.rb
new file mode 100644
index 0000000..eb6e9a8
--- /dev/null
+++ b/app/controllers/teams/billing_controller.rb
@@ -0,0 +1,13 @@
+class Teams::BillingController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def show
+ if current_team.stripe_customer_id.present?
+ portal = current_team.create_billing_portal_session(
+ return_url: team_billing_url(current_team)
+ )
+ @portal_url = portal.url
+ end
+ end
+end
diff --git a/app/controllers/teams/checkouts_controller.rb b/app/controllers/teams/checkouts_controller.rb
new file mode 100644
index 0000000..63fe7f0
--- /dev/null
+++ b/app/controllers/teams/checkouts_controller.rb
@@ -0,0 +1,13 @@
+class Teams::CheckoutsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def create
+ session = current_team.create_checkout_session(
+ price_id: params[:price_id],
+ success_url: team_billing_url(current_team),
+ cancel_url: team_pricing_url(current_team)
+ )
+ redirect_to session.url, allow_other_host: true
+ end
+end
diff --git a/app/controllers/teams/languages_controller.rb b/app/controllers/teams/languages_controller.rb
new file mode 100644
index 0000000..99545a4
--- /dev/null
+++ b/app/controllers/teams/languages_controller.rb
@@ -0,0 +1,28 @@
+class Teams::LanguagesController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def index
+ @active_languages = current_team.team_languages.active.includes(:language).map(&:language)
+ @available_languages = Language.enabled.where.not(id: @active_languages.map(&:id)).by_name
+ end
+
+ def create
+ language = Language.enabled.find(params[:language_id])
+ current_team.enable_language!(language)
+ BackfillTranslationsJob.perform_later(current_team.id, language.code)
+ redirect_to team_languages_path(current_team), notice: t("controllers.teams.languages.create.notice", language: language.localized_name)
+ end
+
+ def destroy
+ language = Language.find(params[:id])
+
+ if current_team.team_languages.active.count <= 1
+ redirect_to team_languages_path(current_team), alert: t("controllers.teams.languages.destroy.cannot_remove_last")
+ return
+ end
+
+ current_team.disable_language!(language)
+ redirect_to team_languages_path(current_team), notice: t("controllers.teams.languages.destroy.notice", language: language.localized_name)
+ end
+end
diff --git a/app/controllers/teams/members_controller.rb b/app/controllers/teams/members_controller.rb
new file mode 100644
index 0000000..f9fd528
--- /dev/null
+++ b/app/controllers/teams/members_controller.rb
@@ -0,0 +1,48 @@
+class Teams::MembersController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!, only: [ :new, :create, :destroy ]
+
+ def index
+ @memberships = current_team.memberships.includes(:user, :invited_by)
+ end
+
+ def show
+ @membership = current_team.memberships.includes(:user, :invited_by).find(params[:id])
+ end
+
+ def new
+ @invite_email = params[:email]
+ end
+
+ def create
+ email = params[:email]
+ user = User.find_or_initialize_by(email: email)
+
+ # Name is collected during onboarding after first login
+ user.save! if user.new_record?
+
+ if user.member_of?(current_team)
+ redirect_to team_members_path(current_team), alert: t("controllers.teams.members.already_member")
+ return
+ end
+
+ token = user.signed_id(purpose: :magic_link, expires_in: 7.days)
+ invite_url = verify_magic_link_url(token: token, team: current_team.slug, invited_by: current_user.id)
+
+ UserMailer.team_invitation(user, current_team, current_user, invite_url).deliver_later
+
+ redirect_to team_members_path(current_team), notice: t("controllers.teams.members.create.notice", email: email)
+ end
+
+ def destroy
+ membership = current_team.memberships.find(params[:id])
+
+ if membership.owner? && current_team.memberships.where(role: "owner").count == 1
+ redirect_to team_members_path(current_team), alert: t("controllers.teams.members.cannot_remove_last_owner")
+ return
+ end
+
+ membership.destroy
+ redirect_to team_members_path(current_team), notice: t("controllers.teams.members.destroy.removed", name: membership.user.name)
+ end
+end
diff --git a/app/controllers/teams/name_checks_controller.rb b/app/controllers/teams/name_checks_controller.rb
new file mode 100644
index 0000000..c6b4fc4
--- /dev/null
+++ b/app/controllers/teams/name_checks_controller.rb
@@ -0,0 +1,11 @@
+class Teams::NameChecksController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def show
+ name = params[:name].to_s.strip
+ taken = name.present? && Team.where.not(id: current_team.id).exists?(name: name)
+
+ render json: { available: !taken }
+ end
+end
diff --git a/app/controllers/teams/pricing_controller.rb b/app/controllers/teams/pricing_controller.rb
new file mode 100644
index 0000000..4e370e2
--- /dev/null
+++ b/app/controllers/teams/pricing_controller.rb
@@ -0,0 +1,9 @@
+class Teams::PricingController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def show
+ @prices = Price.all
+ @monthly_prices, @yearly_prices = @prices.partition { |p| p.interval == "month" }
+ end
+end
diff --git a/app/controllers/teams/settings/api_key_regenerations_controller.rb b/app/controllers/teams/settings/api_key_regenerations_controller.rb
new file mode 100644
index 0000000..1cd5457
--- /dev/null
+++ b/app/controllers/teams/settings/api_key_regenerations_controller.rb
@@ -0,0 +1,9 @@
+class Teams::Settings::ApiKeyRegenerationsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def create
+ current_team.regenerate_api_key!
+ redirect_to team_settings_path(current_team), notice: t("controllers.teams.settings.regenerate_api_key.notice")
+ end
+end
diff --git a/app/controllers/teams/settings_controller.rb b/app/controllers/teams/settings_controller.rb
new file mode 100644
index 0000000..9e0bc50
--- /dev/null
+++ b/app/controllers/teams/settings_controller.rb
@@ -0,0 +1,28 @@
+class Teams::SettingsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def show
+ end
+
+ def edit
+ end
+
+ def update
+ current_team.assign_attributes(team_params)
+
+ if current_team.save
+ redirect_to team_settings_path(current_team.slug), notice: t("controllers.teams.settings.update.notice")
+ else
+ @team_form = current_team.dup.tap { |t| t.errors.merge!(current_team.errors) }
+ current_team.reload
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def team_params
+ params.require(:team).permit(:name, :logo, :remove_logo)
+ end
+end
diff --git a/app/controllers/teams/subscription_cancellations_controller.rb b/app/controllers/teams/subscription_cancellations_controller.rb
new file mode 100644
index 0000000..63a9745
--- /dev/null
+++ b/app/controllers/teams/subscription_cancellations_controller.rb
@@ -0,0 +1,14 @@
+class Teams::SubscriptionCancellationsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :require_team_admin!
+
+ def create
+ current_team.cancel_subscription!
+ redirect_to team_billing_path(current_team), notice: t("controllers.teams.subscription_cancellations.create.notice")
+ end
+
+ def destroy
+ current_team.resume_subscription!
+ redirect_to team_billing_path(current_team), notice: t("controllers.teams.subscription_cancellations.destroy.notice")
+ end
+end
diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb
new file mode 100644
index 0000000..7827172
--- /dev/null
+++ b/app/controllers/teams_controller.rb
@@ -0,0 +1,39 @@
+class TeamsController < ApplicationController
+ before_action :authenticate_user!
+
+ def index
+ @teams = current_user.teams
+
+ if @teams.one?
+ redirect_to team_root_path(@teams.first)
+ elsif @teams.none?
+ team = create_personal_team(current_user)
+ redirect_to team_root_path(team)
+ end
+ end
+
+ def new
+ @team = Team.new
+ end
+
+ def create
+ @team = Team.new(team_params)
+
+ if @team.save
+ @team.memberships.create!(user: current_user, role: "owner")
+ redirect_to team_root_path(@team), notice: t("controllers.teams.create.notice")
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def team_params
+ params.require(:team).permit(:name)
+ end
+
+ def create_personal_team(user)
+ Team.find_or_create_for_user!(user)
+ end
+end
diff --git a/app/controllers/user_settings/newsletters_controller.rb b/app/controllers/user_settings/newsletters_controller.rb
new file mode 100644
index 0000000..32d0f15
--- /dev/null
+++ b/app/controllers/user_settings/newsletters_controller.rb
@@ -0,0 +1,16 @@
+class UserSettings::NewslettersController < ApplicationController
+ before_action :authenticate_user!
+
+ def update
+ current_user.update!(unsubscribed_from_newsletter: !current_user.unsubscribed_from_newsletter)
+ respond_to do |format|
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }),
+ turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" })
+ ]
+ end
+ format.html { redirect_to user_path(current_user) }
+ end
+ end
+end
diff --git a/app/controllers/user_settings/open_to_works_controller.rb b/app/controllers/user_settings/open_to_works_controller.rb
new file mode 100644
index 0000000..9ce6ace
--- /dev/null
+++ b/app/controllers/user_settings/open_to_works_controller.rb
@@ -0,0 +1,18 @@
+class UserSettings::OpenToWorksController < ApplicationController
+ before_action :authenticate_user!
+
+ def update
+ current_user.update!(open_to_work: !current_user.open_to_work)
+ respond_to do |format|
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }),
+ turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }),
+ turbo_stream.replace("profile_avatar_desktop", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_desktop", size: "w-36 h-[130px]", text_size: "text-5xl" }),
+ turbo_stream.replace("profile_avatar_mobile", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_mobile", size: "w-28 h-[100px]", text_size: "text-4xl" })
+ ]
+ end
+ format.html { redirect_to user_path(current_user) }
+ end
+ end
+end
diff --git a/app/controllers/user_settings/repository_hides_controller.rb b/app/controllers/user_settings/repository_hides_controller.rb
new file mode 100644
index 0000000..7663be6
--- /dev/null
+++ b/app/controllers/user_settings/repository_hides_controller.rb
@@ -0,0 +1,34 @@
+class UserSettings::RepositoryHidesController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ repo_url = params.expect(:repo_url)
+ current_user.hide_repository!(repo_url)
+ render_projects_update
+ end
+
+ def destroy
+ repo_url = params.expect(:repo_url)
+ current_user.unhide_repository!(repo_url)
+ render_projects_update
+ end
+
+ private
+
+ def render_projects_update
+ current_user.reload
+ @ruby_repos = current_user.visible_ruby_repositories
+ @hidden_repos = current_user.hidden_ruby_repositories
+
+ respond_to do |format|
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.replace("projects_panel", partial: "users/projects_panel", locals: { ruby_repos: @ruby_repos, hidden_repos: @hidden_repos, user: current_user }),
+ turbo_stream.update("projects_count", html: @ruby_repos.size.to_s),
+ turbo_stream.replace("profile_stars", partial: "users/profile_stars", locals: { user: current_user })
+ ]
+ end
+ format.html { redirect_to user_path(current_user) }
+ end
+ end
+end
diff --git a/app/controllers/user_settings/visibilities_controller.rb b/app/controllers/user_settings/visibilities_controller.rb
new file mode 100644
index 0000000..65b8dc9
--- /dev/null
+++ b/app/controllers/user_settings/visibilities_controller.rb
@@ -0,0 +1,16 @@
+class UserSettings::VisibilitiesController < ApplicationController
+ before_action :authenticate_user!
+
+ def update
+ current_user.update!(public: !current_user.public)
+ respond_to do |format|
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }),
+ turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" })
+ ]
+ end
+ format.html { redirect_to user_path(current_user) }
+ end
+ end
+end
diff --git a/app/controllers/user_settings_controller.rb b/app/controllers/user_settings_controller.rb
deleted file mode 100644
index 6113395..0000000
--- a/app/controllers/user_settings_controller.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-class UserSettingsController < ApplicationController
- before_action :authenticate_user!
-
- def toggle_public
- current_user.update!(public: !current_user.public)
- respond_to do |format|
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }),
- turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" })
- ]
- end
- format.html { redirect_to user_path(current_user) }
- end
- end
-
- def toggle_open_to_work
- current_user.update!(open_to_work: !current_user.open_to_work)
- respond_to do |format|
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }),
- turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }),
- turbo_stream.replace("profile_avatar_desktop", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_desktop", size: "w-36 h-[130px]", text_size: "text-5xl" }),
- turbo_stream.replace("profile_avatar_mobile", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_mobile", size: "w-28 h-[100px]", text_size: "text-4xl" })
- ]
- end
- format.html { redirect_to user_path(current_user) }
- end
- end
-
- def toggle_newsletter
- current_user.update!(unsubscribed_from_newsletter: !current_user.unsubscribed_from_newsletter)
- respond_to do |format|
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }),
- turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" })
- ]
- end
- format.html { redirect_to user_path(current_user) }
- end
- end
-
- def hide_repo
- repo_url = params.expect(:repo_url)
- current_user.hide_repository!(repo_url)
- render_projects_update
- end
-
- def unhide_repo
- repo_url = params.expect(:repo_url)
- current_user.unhide_repository!(repo_url)
- render_projects_update
- end
-
- private
-
- def render_projects_update
- current_user.reload # Reload to get updated stars count
- @ruby_repos = current_user.visible_ruby_repositories
- @hidden_repos = current_user.hidden_ruby_repositories
-
- respond_to do |format|
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.replace("projects_panel", partial: "users/projects_panel", locals: { ruby_repos: @ruby_repos, hidden_repos: @hidden_repos, user: current_user }),
- turbo_stream.update("projects_count", html: @ruby_repos.size.to_s),
- turbo_stream.replace("profile_stars", partial: "users/profile_stars", locals: { user: current_user })
- ]
- end
- format.html { redirect_to user_path(current_user) }
- end
- end
-end
diff --git a/app/controllers/users/map_data_controller.rb b/app/controllers/users/map_data_controller.rb
new file mode 100644
index 0000000..e70ebc1
--- /dev/null
+++ b/app/controllers/users/map_data_controller.rb
@@ -0,0 +1,25 @@
+class Users::MapDataController < ApplicationController
+ def show
+ data = Rails.cache.fetch("community_map_data", expires_in: 1.hour) do
+ User.visible
+ .where.not(latitude: nil, longitude: nil)
+ .select(:id, :slug, :username, :name, :avatar_url, :latitude, :longitude, :open_to_work, :company, :normalized_location)
+ .map { |u|
+ {
+ id: u.id,
+ name: u.display_name,
+ username: u.username,
+ avatar_url: u.avatar_url,
+ lat: u.latitude,
+ lng: u.longitude,
+ open_to_work: u.open_to_work,
+ company: u.company,
+ normalized_location: u.normalized_location,
+ profile_url: helpers.community_user_url(u)
+ }
+ }
+ end
+
+ render json: data
+ end
+end
diff --git a/app/controllers/users/og_images_controller.rb b/app/controllers/users/og_images_controller.rb
new file mode 100644
index 0000000..8a2bbe5
--- /dev/null
+++ b/app/controllers/users/og_images_controller.rb
@@ -0,0 +1,9 @@
+class Users::OgImagesController < ApplicationController
+ def show
+ @users = User.where(public: true)
+ .where.not(avatar_url: [ nil, "" ])
+ .order(Arel.sql("COALESCE(github_stars_sum, 0) + COALESCE(published_posts_count, 0) * 10 + COALESCE(published_comments_count, 0) DESC"))
+ @total_users_count = User.visible.count
+ render layout: false
+ end
+end
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
index e72ffe4..481e5fb 100644
--- a/app/controllers/users/omniauth_callbacks_controller.rb
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -1,16 +1,15 @@
-class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
+class Users::OmniauthCallbacksController < ApplicationController
skip_before_action :verify_authenticity_token, only: :github
def github
- @user = User.from_omniauth(request.env["omniauth.auth"])
+ auth = request.env["omniauth.auth"]
+ @user = User.from_omniauth(auth)
if @user.persisted?
- sign_in @user, event: :authentication
- set_flash_message(:notice, :success, kind: "GitHub") if is_navigational_format?
+ sign_in @user
redirect_to after_sign_in_path, allow_other_host: true
else
- session["devise.github_data"] = request.env["omniauth.auth"].except(:extra)
- redirect_to new_user_registration_url
+ redirect_to root_path, alert: "Authentication failed."
end
end
@@ -23,10 +22,8 @@ def failure
def after_sign_in_path
# Get the original page user was on (stored in session before OAuth)
return_to = session.delete(:return_to)
- session.delete(:from_community) # Clean up, not used anymore
# Determine final destination
- # If specific return_to is set, use it; otherwise go to user profile
final_destination = return_to.presence || user_profile_path
# In production, sync session to other domain first, then return to original page
@@ -36,11 +33,8 @@ def after_sign_in_path
current_host = request.host
token = @user.generate_cross_domain_token!
- # Build full URL for final destination
- # If final_destination is already a full URL (from user_profile_path), use it directly
final_url = final_destination.start_with?("https://") ? final_destination : "https://#{current_host}#{final_destination}"
- # Redirect to other domain to sync session, passing final destination
"https://#{other_host}/auth/receive?token=#{token}&return_to=#{CGI.escape(final_url)}"
else
final_destination
@@ -48,8 +42,6 @@ def after_sign_in_path
end
def user_profile_path
- # In development: /community/:username
- # In production: always go to rubycommunity.org/:username
domains = Rails.application.config.x.domains
if Rails.env.production?
"https://#{domains.community}/#{@user.to_param}"
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index ad5f9b7..379663d 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -1,19 +1,14 @@
-class Users::SessionsController < Devise::SessionsController
+class Users::SessionsController < ApplicationController
def github_auth
# Store the return_to path in session
session[:return_to] = params[:return_to] if params[:return_to].present?
- # Detect if signing in from community pages (to redirect to profile after sign in)
- session[:from_community] = from_community_page?
-
- redirect_to user_github_omniauth_authorize_path, allow_other_host: true
+ redirect_to "/auth/github", allow_other_host: true
end
def destroy
domains = Rails.application.config.x.domains
- # Determine where to redirect after sign out
- # Community pages -> community index, otherwise -> home
return_to_path = from_community_page? ? users_path : "/"
other_host = (request.host == domains.community) ? domains.primary : domains.community
@@ -22,17 +17,14 @@ def destroy
# Generate token for cross-domain sign out
token = current_user&.generate_cross_domain_token!
- signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
- set_flash_message! :notice, :signed_out if signed_out
+ sign_out
if Rails.env.production? && token
- # Sign out on other domain, then come back to this domain
- # On community domain, return to root "/"; on primary domain, redirect to community domain root
prod_return_path = (current_host == domains.community) ? "/" : "https://#{domains.community}/"
final_destination = from_community_page? ? prod_return_path : "https://#{current_host}/"
redirect_to "https://#{other_host}/auth/sign_out_receive?token=#{token}&return_to=#{CGI.escape(final_destination)}", allow_other_host: true
else
- redirect_to return_to_path
+ redirect_to return_to_path, notice: "Signed out successfully."
end
end
@@ -40,13 +32,9 @@ def destroy
def from_community_page?
domains = Rails.application.config.x.domains
- # Check if on community domain
return true if request.host == domains.community
-
- # Check if current path or referer is a community path
return true if request.path.start_with?("/community")
- # Check referer for community pages (for sign-in button clicks)
if request.referer.present?
referer_uri = URI.parse(request.referer) rescue nil
return true if referer_uri && (referer_uri.host == domains.community || referer_uri.path.start_with?("/community"))
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 9f7eb69..e6a73eb 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -82,38 +82,6 @@ def index
@users = @users.page(params[:page]).per(20)
end
- def map_data
- data = Rails.cache.fetch("community_map_data", expires_in: 1.hour) do
- User.visible
- .where.not(latitude: nil, longitude: nil)
- .select(:id, :slug, :username, :name, :avatar_url, :latitude, :longitude, :open_to_work, :company, :normalized_location)
- .map { |u|
- {
- id: u.id,
- name: u.display_name,
- username: u.username,
- avatar_url: u.avatar_url,
- lat: u.latitude,
- lng: u.longitude,
- open_to_work: u.open_to_work,
- company: u.company,
- normalized_location: u.normalized_location,
- profile_url: helpers.community_user_url(u)
- }
- }
- end
-
- render json: data
- end
-
- def og_image
- @users = User.where(public: true)
- .where.not(avatar_url: [ nil, "" ])
- .order(Arel.sql("COALESCE(github_stars_sum, 0) + COALESCE(published_posts_count, 0) * 10 + COALESCE(published_comments_count, 0) DESC"))
- @total_users_count = User.visible.count
- render layout: false
- end
-
def show
@user = User.friendly.find(params[:id])
diff --git a/app/controllers/webhooks/stripe_controller.rb b/app/controllers/webhooks/stripe_controller.rb
new file mode 100644
index 0000000..931e1ac
--- /dev/null
+++ b/app/controllers/webhooks/stripe_controller.rb
@@ -0,0 +1,57 @@
+class Webhooks::StripeController < ActionController::Base
+ skip_forgery_protection
+
+ def create
+ payload = request.body.read
+ sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
+
+ begin
+ event = Stripe::Webhook.construct_event(
+ payload, sig_header, Setting.get(:stripe_webhook_secret)
+ )
+ rescue JSON::ParserError, Stripe::SignatureVerificationError
+ head :bad_request
+ return
+ end
+
+ case event.type
+ when "checkout.session.completed"
+ handle_checkout_completed(event.data.object)
+ when "customer.subscription.updated"
+ handle_subscription_updated(event.data.object)
+ when "customer.subscription.deleted"
+ handle_subscription_deleted(event.data.object)
+ end
+
+ head :ok
+ end
+
+ private
+
+ def handle_checkout_completed(session)
+ team = Team.find_by(stripe_customer_id: session.customer)
+ return unless team
+
+ team.update!(stripe_subscription_id: session.subscription)
+ team.sync_subscription_from_stripe!
+ end
+
+ def handle_subscription_updated(subscription)
+ team = Team.find_by(stripe_customer_id: subscription.customer)
+ return unless team
+
+ team.update!(stripe_subscription_id: subscription.id) unless team.stripe_subscription_id.present?
+ team.sync_subscription_from_stripe!
+ end
+
+ def handle_subscription_deleted(subscription)
+ team = Team.find_by(stripe_customer_id: subscription.customer)
+ return unless team
+
+ team.update!(
+ subscription_status: "canceled",
+ stripe_subscription_id: nil,
+ cancel_at_period_end: false
+ )
+ end
+end
diff --git a/app/helpers/admins/admins_helper.rb b/app/helpers/admins/admins_helper.rb
new file mode 100644
index 0000000..fbe772f
--- /dev/null
+++ b/app/helpers/admins/admins_helper.rb
@@ -0,0 +1,2 @@
+module Admins::AdminsHelper
+end
diff --git a/app/helpers/admins/sessions_helper.rb b/app/helpers/admins/sessions_helper.rb
new file mode 100644
index 0000000..64b1d80
--- /dev/null
+++ b/app/helpers/admins/sessions_helper.rb
@@ -0,0 +1,2 @@
+module Admins::SessionsHelper
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 0ea17cb..0de65b2 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,6 +1,24 @@
module ApplicationHelper
include ImageHelper
+ # ── Open Graph helpers ──
+ def og_title
+ content_for(:og_title).presence || content_for(:title).presence || t("app_name", default: "Why Ruby?")
+ end
+
+ def og_description
+ content_for(:og_description).presence || t("meta.default.summary", default: "")
+ end
+
+ def og_image
+ if content_for?(:og_image)
+ src = content_for(:og_image)
+ src.start_with?("http") ? src : "#{request.base_url}#{src}"
+ else
+ versioned_og_image_url
+ end
+ end
+
# Convert ISO country code to full name via i18n
def country_name(code)
return nil if code.blank?
@@ -19,6 +37,12 @@ def country_name_from_location(normalized_location)
country_name(code)
end
+ # ── Analytics ──
+
+ def nullitics_enabled?
+ Rails.configuration.x.nullitics rescue true
+ end
+
# Get client country code for analytics (ISO 3166-1 alpha-2, e.g., "US", "DE", "CA")
def client_country_code
return @client_country_code if defined?(@client_country_code)
@@ -31,13 +55,32 @@ def client_country_code
nil
end
end
+
+ # ── Markdown ──
+
+ class MarkdownRenderer < Redcarpet::Render::HTML
+ include Rouge::Plugins::Redcarpet
+
+ def block_code(code, language)
+ language ||= "text"
+ formatter = Rouge::Formatters::HTMLLegacy.new(css_class: "highlight")
+ lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
+ formatter.format(lexer.lex(code))
+ end
+ end
+
# Class-level memoized markdown renderer for performance
def self.markdown_renderer
@markdown_renderer ||= begin
- renderer = Redcarpet::Render::HTML.new(
+ renderer = MarkdownRenderer.new(
filter_html: true,
hard_wrap: true,
- link_attributes: { rel: "nofollow", target: "_blank" }
+ link_attributes: { rel: "nofollow", target: "_blank" },
+ fenced_code_blocks: true,
+ prettify: true,
+ tables: true,
+ with_toc_data: true,
+ no_intra_emphasis: true
)
Redcarpet::Markdown.new(renderer,
@@ -57,37 +100,13 @@ def self.markdown_renderer
end
end
- def markdown_to_html(markdown_text)
- return "" if markdown_text.blank?
-
- # Render markdown and apply syntax highlighting
- html = ApplicationHelper.markdown_renderer.render(markdown_text)
-
- # Apply syntax highlighting to code blocks
- doc = Nokogiri::HTML::DocumentFragment.parse(html)
- doc.css("pre code").each do |code_block|
- # Extract language from class attribute (e.g., "ruby" from class="ruby" or "language-ruby")
- language_class = code_block["class"] || ""
- language = language_class.gsub(/^(language-)?/, "") || "text"
-
- begin
- lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText.new
- formatter = Rouge::Formatters::HTML.new
- highlighted_code = formatter.format(lexer.lex(code_block.text))
-
- # Create a new pre element with the highlight class
- pre_element = code_block.parent
- pre_element["class"] = "highlight highlight-#{language}"
-
- # Replace the code block's content with the highlighted version
- code_block.inner_html = highlighted_code
- rescue => e
- # If highlighting fails, keep the original code block
- Rails.logger.error "Syntax highlighting failed for language '#{language}': #{e.message}"
- end
- end
+ def markdown(text)
+ return "" if text.blank?
+ ApplicationHelper.markdown_renderer.render(text).html_safe
+ end
- doc.to_html
+ def markdown_to_html(markdown_text)
+ markdown(markdown_text)
end
def format_post_date(date)
@@ -124,14 +143,12 @@ def post_link_url(post)
end
# Generate full URL to post on primary domain (whyruby.info)
- # Used to ensure posts always link to the content domain, not the community domain
def primary_domain_post_url(post)
domain = Rails.application.config.x.domains.primary
"https://#{domain}/#{post.category.to_param}/#{post.to_param}"
end
# Generate edit post URL on primary domain
- # Used to ensure edit links always go to whyruby.info, not the community domain
def primary_domain_edit_post_url(post)
if Rails.env.production?
domain = Rails.application.config.x.domains.primary
@@ -142,7 +159,6 @@ def primary_domain_edit_post_url(post)
end
# Generate delete post URL on primary domain
- # Used to ensure delete actions always go to whyruby.info, not the community domain
def primary_domain_destroy_post_url(post)
if Rails.env.production?
domain = Rails.application.config.x.domains.primary
@@ -181,79 +197,53 @@ def category_menu_active?(category)
false
end
- # Since success stories now work like regular categories, we can remove this method
- # and just use category_menu_active? for success stories too
-
def community_menu_active?
- # Highlight if on the users index page
return true if current_page?(users_path)
-
- # Highlight if viewing a user profile
controller_name == "users" && action_name == "show"
end
def safe_external_url(url)
return "#" if url.blank?
- # Parse the URL and validate it
begin
uri = URI.parse(url)
-
- # Only allow http, https, and mailto schemes
allowed_schemes = %w[http https mailto]
return "#" unless allowed_schemes.include?(uri.scheme&.downcase)
-
- # Return the original URL if it's safe
url
rescue URI::InvalidURIError
- # If the URL is invalid, return a safe fallback
"#"
end
end
def safe_svg_content(svg_content)
- # This helper makes it explicit that SVG content has been sanitized
- # The actual sanitization happens in the model via SvgSanitizer
- # The SVG is already sanitized, so we can safely mark it as html_safe
return "" if svg_content.blank?
svg_content.html_safe
end
def safe_markdown_content(markdown_text)
- # This helper makes it explicit that markdown has been safely rendered
- # with HTML filtering enabled
markdown_to_html(markdown_text).html_safe
end
# Linkify URLs and GitHub @mentions in user bio text
- # - URLs like "example.com" become clickable links
- # - @username becomes a link to https://github.com/username
def linkify_bio(text)
return "" if text.blank?
- # Escape HTML to prevent XSS
escaped = ERB::Util.html_escape(text)
- # Pattern for GitHub @mentions
github_pattern = /(?<=\s|^)@([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)/
-
- # Pattern for URLs (with or without protocol)
- # Excludes trailing punctuation like commas and periods used in prose
url_pattern = %r{
(?:https?://)? # Optional protocol
(?:www\.)? # Optional www
[a-zA-Z0-9][a-zA-Z0-9\-]* # Domain name
\.[a-zA-Z]{2,} # TLD
- (?:/[^\s,.<>]*)? # Optional path (stops at whitespace, comma, period, angle brackets)
+ (?:/[^\s,<>]*[^\s,.<>])? # Optional path (allow dots mid-path, not trailing)
}x
- # Replace GitHub @mentions first
result = escaped.gsub(github_pattern) do |match|
username = Regexp.last_match(1)
%(#{match})
end
- # Replace URLs (skip github.com since @mentions already handled)
result = result.gsub(url_pattern) do |match|
next match if match.include?("github.com")
url = match.start_with?("http") ? match : "https://#{match}"
@@ -264,7 +254,6 @@ def linkify_bio(text)
end
def has_success_stories?
- # Cache the result for the request to avoid multiple DB queries
if Category.success_story_category
@has_success_stories ||= Category.success_story_category.posts.published.exists?
else
@@ -273,15 +262,10 @@ def has_success_stories?
end
def should_show_mobile_cta?
- # Show CTA when nav is collapsed (below lg) if user is not signed in
- # or if their testimonial is not published
return true unless user_signed_in?
-
- # Check if user has a published testimonial
!current_user.testimonial&.published?
end
- # Generate the full formatted page title that matches the tag format
def full_page_title(page_title = nil)
if page_title.present?
"Why Ruby? — #{page_title}"
@@ -296,13 +280,10 @@ def full_page_title(page_title = nil)
hash[filename] = File.exist?(path) ? File.mtime(path).to_i.to_s : Time.current.to_i.to_s
end
- # Generate versioned URL for OG image to bust social media caches
- # Pass a filename to use a different image (e.g., "og-image-community.png")
def versioned_og_image_url(filename = "og-image.png")
"#{request.base_url}/#{filename}?v=#{OG_IMAGE_VERSIONS[filename]}"
end
- # Generate the full page title for community pages (Ruby Community branding)
def community_page_title(page_title = nil)
if page_title.present?
"Ruby Community — #{page_title}"
@@ -313,10 +294,12 @@ def community_page_title(page_title = nil)
# URL helpers for the new routing structure
def post_url_for(post)
+ return root_url unless post.category
post_url(post.category, post)
end
def post_path_for(post)
+ return root_path unless post.category
post_path(post.category, post)
end
@@ -324,17 +307,14 @@ def post_path_for(post)
def cross_domain_url(domain_type, path = "/")
return path unless Rails.env.production?
- # No Warden context (e.g. rendering from a background job broadcast)
- return path unless respond_to?(:request) && request.present? && request.env["warden"].present?
+ return path unless respond_to?(:request) && request.present?
domains = Rails.application.config.x.domains
host = (domain_type == :primary) ? domains.primary : domains.community
- # If already on target domain, just return the path
return path if request.host == host
if user_signed_in?
- # Sync session to target domain (memoize token for this request)
token = cross_domain_token_for_request
"https://#{host}/auth/receive?token=#{token}&return_to=#{path}"
else
@@ -342,21 +322,16 @@ def cross_domain_url(domain_type, path = "/")
end
end
- # Memoize token per request so multiple links use the same token
def cross_domain_token_for_request
@cross_domain_token ||= current_user.generate_cross_domain_token!
end
- # Helper for community index URL (works in dev and prod)
def community_index_url
return users_path unless Rails.env.production?
domain = Rails.application.config.x.domains.community
-
- # In production on community domain, just go to root
return "/" if request.host == domain
- # On primary domain, cross-domain to community
if user_signed_in?
token = current_user.generate_cross_domain_token!
"https://#{domain}/auth/receive?token=#{token}&return_to=/"
@@ -365,8 +340,6 @@ def community_index_url
end
end
- # Helper for community index path with query params (for pagination/filtering)
- # On community domain in production, uses root path. Otherwise uses /community.
def community_index_path(params = {})
base_path = if Rails.env.production? && request.host == Rails.application.config.x.domains.community
"/"
@@ -380,7 +353,6 @@ def community_index_path(params = {})
query.present? ? "#{base_path}?#{query}" : base_path
end
- # Helper for community user profile URLs (for navigation links)
def community_user_url(user)
if Rails.env.production?
"https://#{Rails.application.config.x.domains.community}/#{user.to_param}"
@@ -389,8 +361,6 @@ def community_user_url(user)
end
end
- # Helper for community user path with query params (for sorting/filtering links)
- # On community domain in production, uses /:id. Otherwise uses /community/:id.
def community_user_path(user, params = {})
base_path = if Rails.env.production? && request.host == Rails.application.config.x.domains.community
"/#{user.to_param}"
@@ -404,7 +374,6 @@ def community_user_path(user, params = {})
query.present? ? "#{base_path}?#{query}" : base_path
end
- # URL for community map data endpoint (works across domains)
def community_map_data_url
if Rails.env.production? && request.host == Rails.application.config.x.domains.community
"/map_data"
@@ -413,8 +382,6 @@ def community_map_data_url
end
end
- # Generate a full URL on the primary domain (whyruby.info) for a given path.
- # Used for footer legal links that must resolve on both domains.
def main_site_url(path)
if Rails.env.production? && request.host == Rails.application.config.x.domains.community
"https://#{Rails.application.config.x.domains.primary}#{path}"
@@ -423,9 +390,6 @@ def main_site_url(path)
end
end
- # Canonical URL for community root (for meta tags)
- # Production: https://rubycommunity.org/
- # Development: http://localhost:3003/community
def community_root_canonical_url
if Rails.env.production?
"https://#{Rails.application.config.x.domains.community}/"
@@ -434,9 +398,6 @@ def community_root_canonical_url
end
end
- # Canonical URL for community user profile (for meta tags)
- # Production: https://rubycommunity.org/username
- # Development: http://localhost:3003/community/username
def community_user_canonical_url(user)
if Rails.env.production?
"https://#{Rails.application.config.x.domains.community}/#{user.to_param}"
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
new file mode 100644
index 0000000..23de56a
--- /dev/null
+++ b/app/helpers/home_helper.rb
@@ -0,0 +1,2 @@
+module HomeHelper
+end
diff --git a/app/helpers/madmin/application_helper.rb b/app/helpers/madmin/application_helper.rb
new file mode 100644
index 0000000..8edb600
--- /dev/null
+++ b/app/helpers/madmin/application_helper.rb
@@ -0,0 +1,104 @@
+module Madmin
+ module ApplicationHelper
+ include Pagy::Frontend if defined?(Pagy::Frontend)
+
+ # Navigation link helper for Madmin sidebar
+ def madmin_nav_link(path, icon, label, nested: false)
+ is_active = current_page?(path) || (path != "/madmin" && request.path.start_with?(path.to_s.split("?").first))
+
+ base_classes = "flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
+ active_classes = "bg-dark-800 text-white"
+ inactive_classes = "text-dark-400 hover:text-dark-100 hover:bg-dark-800"
+ nested_classes = nested ? "pl-10" : ""
+
+ link_to path, class: "#{base_classes} #{is_active ? active_classes : inactive_classes} #{nested_classes}" do
+ inline_svg("icons/#{icon}.svg", class: "w-5 h-5 flex-shrink-0") + content_tag(:span, label)
+ end
+ end
+
+ # Collapsible navigation group for Madmin sidebar
+ def madmin_nav_group(id, icon, label, &block)
+ content = capture(&block)
+
+ content_tag(:div, class: "space-y-1") do
+ button = content_tag(:button,
+ type: "button",
+ data: { action: "click->sidebar#toggleGroup", group_id: id },
+ class: "w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-dark-400 hover:text-dark-100 hover:bg-dark-800 rounded-lg transition-colors") do
+ icon_and_label = content_tag(:div, class: "flex items-center gap-3") do
+ inline_svg("icons/#{icon}.svg", class: "w-5 h-5 flex-shrink-0") + content_tag(:span, label)
+ end
+ chevron = inline_svg("icons/chevron-right.svg", class: "w-4 h-4 transition-transform duration-200 rotate-90", data: { chevron: true })
+ icon_and_label + chevron
+ end
+
+ group_content = content_tag(:div, content, id: "nav-group-#{id}", class: "space-y-1")
+ button + group_content
+ end
+ end
+
+ def mask_secret(value)
+ return nil if value.blank?
+
+ "***#{value.last(4)}"
+ end
+
+ # Sidebar entity counts, memoized per request
+ def madmin_sidebar_counts
+ @madmin_sidebar_counts ||= Rails.cache.fetch("madmin_sidebar_counts", expires_in: 2.minutes) do
+ {
+ posts: Post.unscoped.count,
+ categories: Category.count,
+ tags: Tag.count,
+ comments: Comment.count,
+ reports: Report.count,
+ testimonials: Testimonial.count,
+ users: User.count,
+ teams: Team.count,
+ projects: Project.count
+ }
+ end
+ end
+
+ class MarkdownRenderer < Redcarpet::Render::HTML
+ include Rouge::Plugins::Redcarpet
+
+ def block_code(code, language)
+ language ||= "text"
+ formatter = Rouge::Formatters::HTMLLegacy.new(css_class: "highlight")
+ lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
+ formatter.format(lexer.lex(code))
+ end
+ end
+
+ def markdown(text)
+ return "" if text.blank?
+
+ options = {
+ filter_html: true,
+ hard_wrap: true,
+ link_attributes: { rel: "nofollow", target: "_blank" },
+ fenced_code_blocks: true,
+ prettify: true,
+ tables: true,
+ with_toc_data: true,
+ no_intra_emphasis: true
+ }
+
+ extensions = {
+ autolink: true,
+ superscript: true,
+ disable_indented_code_blocks: true,
+ fenced_code_blocks: true,
+ tables: true,
+ strikethrough: true,
+ highlight: true
+ }
+
+ renderer = MarkdownRenderer.new(options)
+ markdown_parser = Redcarpet::Markdown.new(renderer, extensions)
+
+ markdown_parser.render(text).html_safe
+ end
+ end
+end
diff --git a/app/helpers/madmin/settings_helper.rb b/app/helpers/madmin/settings_helper.rb
new file mode 100644
index 0000000..d64073e
--- /dev/null
+++ b/app/helpers/madmin/settings_helper.rb
@@ -0,0 +1,4 @@
+module Madmin
+ module SettingsHelper
+ end
+end
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
new file mode 100644
index 0000000..309f8b2
--- /dev/null
+++ b/app/helpers/sessions_helper.rb
@@ -0,0 +1,2 @@
+module SessionsHelper
+end
diff --git a/app/javascript/controllers/auto_width_select_controller.js b/app/javascript/controllers/auto_width_select_controller.js
new file mode 100644
index 0000000..18ba5e0
--- /dev/null
+++ b/app/javascript/controllers/auto_width_select_controller.js
@@ -0,0 +1,23 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ connect() {
+ this.resize()
+ }
+
+ resize() {
+ const tempSpan = document.createElement("span")
+ tempSpan.style.visibility = "hidden"
+ tempSpan.style.position = "absolute"
+ tempSpan.style.whiteSpace = "nowrap"
+ tempSpan.style.font = window.getComputedStyle(this.element).font
+ tempSpan.textContent = this.element.options[this.element.selectedIndex]?.text || ""
+ document.body.appendChild(tempSpan)
+
+ const textWidth = tempSpan.offsetWidth
+ document.body.removeChild(tempSpan)
+
+ // Add padding for the chevron icon and some buffer
+ this.element.style.width = `${textWidth + 32}px`
+ }
+}
diff --git a/app/javascript/controllers/chat_input_controller.js b/app/javascript/controllers/chat_input_controller.js
new file mode 100644
index 0000000..01d3504
--- /dev/null
+++ b/app/javascript/controllers/chat_input_controller.js
@@ -0,0 +1,126 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["textarea", "form", "fileInput", "attachButton", "previewArea", "inputContainer"]
+
+ connect() {
+ this.resize()
+ this.selectedFiles = []
+ this.textareaTarget.focus()
+ }
+
+ resize() {
+ const textarea = this.textareaTarget
+ textarea.style.height = "auto"
+ textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"
+ }
+
+ submit(event) {
+ if (event.key === "Enter") {
+ if (event.altKey) {
+ // Alt/Option+Enter inserts a new line
+ event.preventDefault()
+ const textarea = this.textareaTarget
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const value = textarea.value
+ textarea.value = value.substring(0, start) + "\n" + value.substring(end)
+ textarea.selectionStart = textarea.selectionEnd = start + 1
+ this.resize()
+ } else {
+ // Enter submits the form
+ event.preventDefault()
+ if (this.textareaTarget.value.trim() || this.selectedFiles.length > 0) {
+ this.formTarget.requestSubmit()
+ this.scrollToBottom()
+ }
+ }
+ }
+ }
+
+ triggerFileInput() {
+ if (this.hasFileInputTarget) {
+ this.fileInputTarget.click()
+ }
+ }
+
+ handleFileSelect(event) {
+ const newFiles = Array.from(event.target.files)
+ if (newFiles.length > 0) {
+ this.selectedFiles = [...this.selectedFiles, ...newFiles]
+ this.syncFileInput()
+ this.showAttachmentPreview()
+ }
+ }
+
+ syncFileInput() {
+ if (this.hasFileInputTarget) {
+ const dt = new DataTransfer()
+ this.selectedFiles.forEach(file => dt.items.add(file))
+ this.fileInputTarget.files = dt.files
+ }
+ }
+
+ showAttachmentPreview() {
+ if (!this.hasPreviewAreaTarget) return
+
+ this.previewAreaTarget.classList.remove("hidden")
+ this.previewAreaTarget.replaceChildren()
+
+ this.selectedFiles.forEach((file, index) => {
+ const preview = document.createElement("div")
+ preview.className = "relative group w-16 h-16"
+
+ if (file.type.startsWith("image/")) {
+ const img = document.createElement("img")
+ img.className = "w-16 h-16 object-cover rounded-lg"
+ img.src = URL.createObjectURL(file)
+ img.onload = () => URL.revokeObjectURL(img.src)
+ preview.appendChild(img)
+ } else {
+ const container = document.createElement("div")
+ container.className = "w-16 h-16 bg-dark-700 rounded-lg flex flex-col items-center justify-center"
+ const ext = file.name.split('.').pop()?.toUpperCase() || 'FILE'
+ const icon = document.createElement("span")
+ icon.className = "text-lg"
+ icon.textContent = file.type === "application/pdf" ? "📕" : "📄"
+ const label = document.createElement("span")
+ label.className = "text-[9px] text-dark-400 truncate w-full text-center px-1"
+ label.textContent = ext
+ container.appendChild(icon)
+ container.appendChild(label)
+ preview.appendChild(container)
+ }
+
+ const removeBtn = document.createElement("button")
+ removeBtn.type = "button"
+ removeBtn.className = "absolute -top-1 -right-1 w-5 h-5 bg-dark-600 hover:bg-dark-500 text-dark-200 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
+ removeBtn.dataset.index = index
+ removeBtn.dataset.action = "click->chat-input#removeFile"
+ removeBtn.textContent = "×"
+ preview.appendChild(removeBtn)
+
+ this.previewAreaTarget.appendChild(preview)
+ })
+ }
+
+ scrollToBottom() {
+ const scrollContainer = document.querySelector('[data-controller="scroll-bottom"]')
+ if (scrollContainer) {
+ scrollContainer.scrollTop = scrollContainer.scrollHeight
+ }
+ }
+
+ removeFile(event) {
+ const index = parseInt(event.target.dataset.index)
+ this.selectedFiles.splice(index, 1)
+ this.syncFileInput()
+
+ if (this.selectedFiles.length === 0 && this.hasPreviewAreaTarget) {
+ this.previewAreaTarget.classList.add("hidden")
+ this.previewAreaTarget.replaceChildren()
+ } else {
+ this.showAttachmentPreview()
+ }
+ }
+}
diff --git a/app/javascript/controllers/image_upload_controller.js b/app/javascript/controllers/image_upload_controller.js
new file mode 100644
index 0000000..54c81f2
--- /dev/null
+++ b/app/javascript/controllers/image_upload_controller.js
@@ -0,0 +1,94 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["input", "dropzone", "placeholder", "preview", "previewImage", "removeFlag", "spinner"]
+
+ connect() {
+ this.form = this.element.closest("form")
+ if (this.form) {
+ this.submitStart = () => this.showSpinner()
+ this.submitEnd = () => this.hideSpinner()
+ this.form.addEventListener("turbo:submit-start", this.submitStart)
+ this.form.addEventListener("turbo:submit-end", this.submitEnd)
+ }
+ }
+
+ disconnect() {
+ if (this.form) {
+ this.form.removeEventListener("turbo:submit-start", this.submitStart)
+ this.form.removeEventListener("turbo:submit-end", this.submitEnd)
+ }
+ }
+
+ showSpinner() {
+ if (this.hasSpinnerTarget && this.inputTarget.files.length > 0) {
+ this.spinnerTarget.classList.remove("hidden")
+ }
+ }
+
+ hideSpinner() {
+ if (this.hasSpinnerTarget) {
+ this.spinnerTarget.classList.add("hidden")
+ }
+ }
+
+ browse(event) {
+ if (event.target.closest("button[data-action*='clear']")) return
+ this.inputTarget.click()
+ }
+
+ dragover(event) {
+ event.preventDefault()
+ this.dropzoneTarget.classList.add("border-accent-500")
+ }
+
+ dragleave() {
+ this.dropzoneTarget.classList.remove("border-accent-500")
+ }
+
+ drop(event) {
+ event.preventDefault()
+ this.dropzoneTarget.classList.remove("border-accent-500")
+
+ const file = event.dataTransfer.files[0]
+ if (file && file.type.startsWith("image/")) {
+ this.setFile(file)
+ }
+ }
+
+ inputTargetConnected() {
+ this.inputTarget.addEventListener("change", () => {
+ const file = this.inputTarget.files[0]
+ if (file) this.showPreview(file)
+ })
+ }
+
+ setFile(file) {
+ const dt = new DataTransfer()
+ dt.items.add(file)
+ this.inputTarget.files = dt.files
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
+ this.showPreview(file)
+ }
+
+ showPreview(file) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ this.previewImageTarget.src = e.target.result
+ this.placeholderTarget.classList.add("hidden")
+ this.previewTarget.classList.remove("hidden")
+ if (this.hasRemoveFlagTarget) this.removeFlagTarget.value = "0"
+ }
+ reader.readAsDataURL(file)
+ }
+
+ clear(event) {
+ event.stopPropagation()
+ this.inputTarget.value = ""
+ this.previewTarget.classList.add("hidden")
+ this.placeholderTarget.classList.remove("hidden")
+ this.previewImageTarget.src = ""
+ if (this.hasRemoveFlagTarget) this.removeFlagTarget.value = "1"
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
+ }
+}
diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js
new file mode 100644
index 0000000..5e2c45f
--- /dev/null
+++ b/app/javascript/controllers/lightbox_controller.js
@@ -0,0 +1,114 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ url: String,
+ filename: String,
+ type: String
+ }
+
+ open(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const overlay = document.createElement("div")
+ overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/90"
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) this.close()
+ })
+
+ const container = document.createElement("div")
+ container.className = "relative max-w-[90vw] max-h-[90vh] flex flex-col"
+ container.addEventListener("click", (e) => e.stopPropagation())
+
+ if (this.typeValue === "image") {
+ const img = document.createElement("img")
+ img.src = this.urlValue
+ img.className = "max-w-full max-h-[calc(90vh-60px)] object-contain rounded-lg"
+ img.alt = this.filenameValue
+ container.appendChild(img)
+ } else {
+ const preview = document.createElement("div")
+ preview.className = "bg-dark-800 rounded-lg p-8 flex flex-col items-center gap-4 min-w-[300px]"
+ const icon = document.createElement("span")
+ icon.className = "text-6xl"
+ icon.textContent = this.typeValue === "pdf" || this.filenameValue.endsWith(".pdf") ? "📕" : "📄"
+ const name = document.createElement("span")
+ name.className = "text-dark-200 text-lg font-medium text-center"
+ name.textContent = this.filenameValue
+ preview.appendChild(icon)
+ preview.appendChild(name)
+ container.appendChild(preview)
+ }
+
+ const toolbar = document.createElement("div")
+ toolbar.className = "flex items-center justify-center gap-4 mt-4"
+
+ const downloadBtn = document.createElement("a")
+ downloadBtn.href = this.urlValue
+ downloadBtn.download = this.filenameValue
+ downloadBtn.className = "flex items-center gap-2 bg-dark-700 hover:bg-dark-600 text-white px-4 py-2 rounded-lg transition-colors"
+ const downloadIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
+ downloadIcon.setAttribute("class", "w-5 h-5")
+ downloadIcon.setAttribute("fill", "none")
+ downloadIcon.setAttribute("stroke", "currentColor")
+ downloadIcon.setAttribute("viewBox", "0 0 24 24")
+ const downloadPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
+ downloadPath.setAttribute("stroke-linecap", "round")
+ downloadPath.setAttribute("stroke-linejoin", "round")
+ downloadPath.setAttribute("stroke-width", "2")
+ downloadPath.setAttribute("d", "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4")
+ downloadIcon.appendChild(downloadPath)
+ downloadBtn.appendChild(downloadIcon)
+ downloadBtn.appendChild(document.createTextNode(" Download"))
+ toolbar.appendChild(downloadBtn)
+
+ const closeBtn = document.createElement("button")
+ closeBtn.type = "button"
+ closeBtn.className = "flex items-center gap-2 bg-dark-700 hover:bg-dark-600 text-white px-4 py-2 rounded-lg transition-colors"
+ const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
+ closeIcon.setAttribute("class", "w-5 h-5")
+ closeIcon.setAttribute("fill", "none")
+ closeIcon.setAttribute("stroke", "currentColor")
+ closeIcon.setAttribute("viewBox", "0 0 24 24")
+ const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path")
+ closePath.setAttribute("stroke-linecap", "round")
+ closePath.setAttribute("stroke-linejoin", "round")
+ closePath.setAttribute("stroke-width", "2")
+ closePath.setAttribute("d", "M6 18L18 6M6 6l12 12")
+ closeIcon.appendChild(closePath)
+ closeBtn.appendChild(closeIcon)
+ closeBtn.appendChild(document.createTextNode(" Close"))
+ closeBtn.addEventListener("click", (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ this.close()
+ })
+ toolbar.appendChild(closeBtn)
+
+ container.appendChild(toolbar)
+ overlay.appendChild(container)
+ document.body.appendChild(overlay)
+ this.overlay = overlay
+
+ document.addEventListener("keydown", this.handleKeydown)
+ }
+
+ handleKeydown = (event) => {
+ if (event.key === "Escape") {
+ this.close()
+ }
+ }
+
+ close() {
+ if (this.overlay) {
+ this.overlay.remove()
+ this.overlay = null
+ document.removeEventListener("keydown", this.handleKeydown)
+ }
+ }
+
+ disconnect() {
+ this.close()
+ }
+}
diff --git a/app/javascript/controllers/link_metadata_controller.js b/app/javascript/controllers/link_metadata_controller.js
index 087eef1..b896b83 100644
--- a/app/javascript/controllers/link_metadata_controller.js
+++ b/app/javascript/controllers/link_metadata_controller.js
@@ -27,7 +27,7 @@ export default class extends Controller {
const postId = form ? (form.dataset.postId || form.getAttribute('data-post-id')) : null
try {
- const response = await fetch('/posts/fetch_metadata', {
+ const response = await fetch('/posts/metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -170,7 +170,7 @@ export default class extends Controller {
const postId = form ? (form.dataset.postId || form.getAttribute('data-post-id')) : null
try {
- const response = await fetch('/posts/check_duplicate_url', {
+ const response = await fetch('/posts/duplicate_check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/app/javascript/controllers/model_select_controller.js b/app/javascript/controllers/model_select_controller.js
new file mode 100644
index 0000000..06a2977
--- /dev/null
+++ b/app/javascript/controllers/model_select_controller.js
@@ -0,0 +1,45 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["button", "menu", "input", "label"]
+
+ connect() {
+ this.closeOnClickOutside = this.closeOnClickOutside.bind(this)
+ }
+
+ toggle() {
+ if (this.menuTarget.classList.contains("hidden")) {
+ this.open()
+ } else {
+ this.close()
+ }
+ }
+
+ open() {
+ this.menuTarget.classList.remove("hidden")
+ document.addEventListener("click", this.closeOnClickOutside)
+ }
+
+ close() {
+ this.menuTarget.classList.add("hidden")
+ document.removeEventListener("click", this.closeOnClickOutside)
+ }
+
+ closeOnClickOutside(event) {
+ if (!this.element.contains(event.target)) {
+ this.close()
+ }
+ }
+
+ select(event) {
+ const value = event.currentTarget.dataset.value
+ const label = event.currentTarget.dataset.label
+ this.inputTarget.value = value
+ this.labelTarget.textContent = label
+ this.close()
+ }
+
+ disconnect() {
+ document.removeEventListener("click", this.closeOnClickOutside)
+ }
+}
diff --git a/app/javascript/controllers/name_check_controller.js b/app/javascript/controllers/name_check_controller.js
new file mode 100644
index 0000000..785d709
--- /dev/null
+++ b/app/javascript/controllers/name_check_controller.js
@@ -0,0 +1,90 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["input", "available", "taken", "submit"]
+ static values = {
+ url: String,
+ original: String
+ }
+
+ connect() {
+ this.timeout = null
+ }
+
+ disconnect() {
+ if (this.timeout) clearTimeout(this.timeout)
+ }
+
+ check() {
+ if (this.timeout) clearTimeout(this.timeout)
+
+ const name = this.inputTarget.value.trim()
+
+ if (!name) {
+ this.hideStatus()
+ this.disableSubmit()
+ return
+ }
+
+ if (name === this.originalValue) {
+ this.hideStatus()
+ this.enableSubmit()
+ return
+ }
+
+ this.timeout = setTimeout(() => this.fetchAvailability(name), 300)
+ }
+
+ async fetchAvailability(name) {
+ try {
+ const url = new URL(this.urlValue, window.location.origin)
+ url.searchParams.set("name", name)
+
+ const response = await fetch(url, {
+ headers: { "Accept": "application/json" }
+ })
+ const data = await response.json()
+
+ if (this.inputTarget.value.trim() !== name) return
+
+ if (data.available) {
+ this.showAvailable()
+ this.enableSubmit()
+ } else {
+ this.showTaken()
+ this.disableSubmit()
+ }
+ } catch {
+ this.hideStatus()
+ this.enableSubmit()
+ }
+ }
+
+ showAvailable() {
+ this.availableTarget.classList.remove("hidden")
+ this.takenTarget.classList.add("hidden")
+ this.inputTarget.classList.remove("border-red-500", "focus:border-red-500")
+ this.inputTarget.classList.add("border-green-500", "focus:border-green-500")
+ }
+
+ showTaken() {
+ this.takenTarget.classList.remove("hidden")
+ this.availableTarget.classList.add("hidden")
+ this.inputTarget.classList.remove("border-green-500", "focus:border-green-500")
+ this.inputTarget.classList.add("border-red-500", "focus:border-red-500")
+ }
+
+ hideStatus() {
+ this.availableTarget.classList.add("hidden")
+ this.takenTarget.classList.add("hidden")
+ this.inputTarget.classList.remove("border-green-500", "focus:border-green-500", "border-red-500", "focus:border-red-500")
+ }
+
+ enableSubmit() {
+ if (this.hasSubmitTarget) this.submitTarget.disabled = false
+ }
+
+ disableSubmit() {
+ if (this.hasSubmitTarget) this.submitTarget.disabled = true
+ }
+}
diff --git a/app/javascript/controllers/pricing_toggle_controller.js b/app/javascript/controllers/pricing_toggle_controller.js
new file mode 100644
index 0000000..3a1c633
--- /dev/null
+++ b/app/javascript/controllers/pricing_toggle_controller.js
@@ -0,0 +1,27 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["monthlyButton", "yearlyButton", "monthlyPrices", "yearlyPrices"]
+
+ connect() {
+ this.showMonthly()
+ }
+
+ showMonthly() {
+ this.monthlyPricesTarget.classList.remove("hidden")
+ this.yearlyPricesTarget.classList.add("hidden")
+ this.monthlyButtonTarget.classList.add("bg-accent-600", "text-white")
+ this.monthlyButtonTarget.classList.remove("text-dark-400")
+ this.yearlyButtonTarget.classList.remove("bg-accent-600", "text-white")
+ this.yearlyButtonTarget.classList.add("text-dark-400")
+ }
+
+ showYearly() {
+ this.yearlyPricesTarget.classList.remove("hidden")
+ this.monthlyPricesTarget.classList.add("hidden")
+ this.yearlyButtonTarget.classList.add("bg-accent-600", "text-white")
+ this.yearlyButtonTarget.classList.remove("text-dark-400")
+ this.monthlyButtonTarget.classList.remove("bg-accent-600", "text-white")
+ this.monthlyButtonTarget.classList.add("text-dark-400")
+ }
+}
diff --git a/app/javascript/controllers/scroll_bottom_controller.js b/app/javascript/controllers/scroll_bottom_controller.js
new file mode 100644
index 0000000..a1ecb08
--- /dev/null
+++ b/app/javascript/controllers/scroll_bottom_controller.js
@@ -0,0 +1,40 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ connect() {
+ this.scrollToBottom()
+ this.observeNewMessages()
+ this.observeImageLoads()
+ }
+
+ disconnect() {
+ this.observer?.disconnect()
+ }
+
+ observeNewMessages() {
+ this.observer = new MutationObserver(() => {
+ this.scrollToBottom()
+ this.observeImageLoads()
+ })
+
+ this.observer.observe(this.element, {
+ childList: true,
+ subtree: true
+ })
+ }
+
+ observeImageLoads() {
+ this.element.querySelectorAll("img:not([data-scroll-observed])").forEach(img => {
+ img.dataset.scrollObserved = "true"
+ if (!img.complete) {
+ img.addEventListener("load", () => this.scrollToBottom(), { once: true })
+ }
+ })
+ }
+
+ scrollToBottom() {
+ requestAnimationFrame(() => {
+ this.element.scrollTop = this.element.scrollHeight
+ })
+ }
+}
diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js
new file mode 100644
index 0000000..eda8fad
--- /dev/null
+++ b/app/javascript/controllers/sidebar_controller.js
@@ -0,0 +1,212 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["sidebar", "overlay", "content", "navGroup", "label", "collapseIcon", "mainContent"]
+ static values = {
+ open: { type: Boolean, default: false },
+ collapsed: { type: Array, default: [] },
+ minimized: { type: Boolean, default: false }
+ }
+
+ connect() {
+ this.loadCollapsedState()
+ this.loadMinimizedState()
+ this.checkMobileView()
+ window.addEventListener("resize", this.handleResize.bind(this))
+ }
+
+ disconnect() {
+ window.removeEventListener("resize", this.handleResize.bind(this))
+ }
+
+ toggle() {
+ this.openValue = !this.openValue
+ this.updateSidebarVisibility()
+ }
+
+ open() {
+ this.openValue = true
+ this.updateSidebarVisibility()
+ }
+
+ close() {
+ this.openValue = false
+ this.updateSidebarVisibility()
+ }
+
+ toggleMinimize() {
+ this.minimizedValue = !this.minimizedValue
+ this.updateMinimizedState()
+ this.saveMinimizedState()
+ }
+
+ loadMinimizedState() {
+ try {
+ const saved = localStorage.getItem("sidebar-minimized")
+ if (saved !== null) {
+ this.minimizedValue = JSON.parse(saved)
+ this.updateMinimizedState()
+ }
+ } catch (e) {
+ console.warn("Could not load minimized state:", e)
+ }
+ }
+
+ saveMinimizedState() {
+ try {
+ localStorage.setItem("sidebar-minimized", JSON.stringify(this.minimizedValue))
+ } catch (e) {
+ console.warn("Could not save minimized state:", e)
+ }
+ }
+
+ updateMinimizedState() {
+ if (!this.hasSidebarTarget) return
+
+ if (this.minimizedValue) {
+ this.sidebarTarget.classList.add("sidebar-collapsed")
+ this.sidebarTarget.style.setProperty("--sidebar-width", "4rem")
+ this.forceReflow(this.sidebarTarget)
+ this.labelTargets.forEach(el => {
+ el.classList.add("opacity-0", "w-0", "overflow-hidden")
+ el.classList.remove("opacity-100")
+ })
+ this.collapseIconTargets.forEach(el => {
+ el.classList.add("rotate-180")
+ })
+ if (this.hasMainContentTarget) {
+ this.mainContentTarget.classList.add("sidebar-collapsed")
+ this.forceReflow(this.mainContentTarget)
+ }
+ } else {
+ this.sidebarTarget.classList.remove("sidebar-collapsed")
+ this.sidebarTarget.style.setProperty("--sidebar-width", "16rem")
+ this.forceReflow(this.sidebarTarget)
+ this.labelTargets.forEach(el => {
+ el.classList.remove("opacity-0", "w-0", "overflow-hidden")
+ el.classList.add("opacity-100")
+ })
+ this.collapseIconTargets.forEach(el => {
+ el.classList.remove("rotate-180")
+ })
+ if (this.hasMainContentTarget) {
+ this.mainContentTarget.classList.remove("sidebar-collapsed")
+ this.forceReflow(this.mainContentTarget)
+ }
+ }
+ }
+
+ forceReflow(element) {
+ const display = element.style.display
+ element.style.display = "none"
+ void element.offsetHeight
+ element.style.display = display || ""
+ }
+
+ updateSidebarVisibility() {
+ if (this.hasSidebarTarget) {
+ if (this.openValue) {
+ this.sidebarTarget.classList.remove("-translate-x-full")
+ this.sidebarTarget.classList.add("translate-x-0")
+ } else {
+ this.sidebarTarget.classList.add("-translate-x-full")
+ this.sidebarTarget.classList.remove("translate-x-0")
+ }
+ }
+
+ if (this.hasOverlayTarget) {
+ if (this.openValue) {
+ this.overlayTarget.classList.remove("hidden")
+ this.overlayTarget.classList.add("block")
+ } else {
+ this.overlayTarget.classList.add("hidden")
+ this.overlayTarget.classList.remove("block")
+ }
+ }
+ }
+
+ toggleGroup(event) {
+ const groupId = event.currentTarget.dataset.groupId
+ const groupContent = document.getElementById(`nav-group-${groupId}`)
+ const chevron = event.currentTarget.querySelector("[data-chevron]")
+
+ if (!groupContent) return
+
+ const isCollapsed = groupContent.classList.contains("hidden")
+
+ if (isCollapsed) {
+ groupContent.classList.remove("hidden")
+ chevron?.classList.add("rotate-90")
+ this.removeFromCollapsed(groupId)
+ } else {
+ groupContent.classList.add("hidden")
+ chevron?.classList.remove("rotate-90")
+ this.addToCollapsed(groupId)
+ }
+
+ this.saveCollapsedState()
+ }
+
+ loadCollapsedState() {
+ try {
+ const saved = localStorage.getItem("sidebar-collapsed-groups")
+ if (saved) {
+ this.collapsedValue = JSON.parse(saved)
+ this.applyCollapsedState()
+ }
+ } catch (e) {
+ console.warn("Could not load sidebar state:", e)
+ }
+ }
+
+ saveCollapsedState() {
+ try {
+ localStorage.setItem("sidebar-collapsed-groups", JSON.stringify(this.collapsedValue))
+ } catch (e) {
+ console.warn("Could not save sidebar state:", e)
+ }
+ }
+
+ applyCollapsedState() {
+ this.collapsedValue.forEach(groupId => {
+ const groupContent = document.getElementById(`nav-group-${groupId}`)
+ const trigger = document.querySelector(`[data-group-id="${groupId}"]`)
+ const chevron = trigger?.querySelector("[data-chevron]")
+
+ if (groupContent) {
+ groupContent.classList.add("hidden")
+ }
+ if (chevron) {
+ chevron.classList.remove("rotate-90")
+ }
+ })
+ }
+
+ addToCollapsed(groupId) {
+ if (!this.collapsedValue.includes(groupId)) {
+ this.collapsedValue = [...this.collapsedValue, groupId]
+ }
+ }
+
+ removeFromCollapsed(groupId) {
+ this.collapsedValue = this.collapsedValue.filter(id => id !== groupId)
+ }
+
+ handleResize() {
+ this.checkMobileView()
+ }
+
+ checkMobileView() {
+ const isMobile = window.innerWidth < 1024
+ if (!isMobile && this.hasSidebarTarget) {
+ this.sidebarTarget.classList.remove("-translate-x-full")
+ this.sidebarTarget.classList.add("translate-x-0")
+ if (this.hasOverlayTarget) {
+ this.overlayTarget.classList.add("hidden")
+ }
+ } else if (isMobile && !this.openValue && this.hasSidebarTarget) {
+ this.sidebarTarget.classList.add("-translate-x-full")
+ this.sidebarTarget.classList.remove("translate-x-0")
+ }
+ }
+}
diff --git a/app/javascript/controllers/typing_indicator_controller.js b/app/javascript/controllers/typing_indicator_controller.js
new file mode 100644
index 0000000..f8a8430
--- /dev/null
+++ b/app/javascript/controllers/typing_indicator_controller.js
@@ -0,0 +1,24 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["dots"]
+
+ connect() {
+ this.observer = new MutationObserver(() => {
+ if (this.hasDots && this.element.childNodes.length > 1) {
+ this.dotsTarget.remove()
+ this.observer.disconnect()
+ }
+ })
+
+ this.observer.observe(this.element, { childList: true })
+ }
+
+ get hasDots() {
+ return this.hasDotsTarget
+ }
+
+ disconnect() {
+ this.observer?.disconnect()
+ }
+}
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
index d394c3d..a6bbc43 100644
--- a/app/jobs/application_job.rb
+++ b/app/jobs/application_job.rb
@@ -4,4 +4,9 @@ class ApplicationJob < ActiveJob::Base
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
+
+ # Ensure RubyLLM has fresh credentials from DB before each job.
+ # Config lives in process memory, so worker processes won't see
+ # credentials saved by the web process after boot without this.
+ before_perform { ProviderCredential.configure_ruby_llm! }
end
diff --git a/app/jobs/backfill_translations_job.rb b/app/jobs/backfill_translations_job.rb
new file mode 100644
index 0000000..a547a56
--- /dev/null
+++ b/app/jobs/backfill_translations_job.rb
@@ -0,0 +1,23 @@
+class BackfillTranslationsJob < ApplicationJob
+ def perform(team_id, target_locale)
+ team = Team.find_by(id: team_id)
+ return unless team
+
+ translatable_models.each do |model_class|
+ records = model_class.where(team_id: team.id)
+ next if records.empty?
+
+ jobs = records.map do |record|
+ TranslateContentJob.new(model_class.name, record.id, I18n.default_locale.to_s, target_locale)
+ end
+
+ ActiveJob.perform_all_later(jobs)
+ end
+ end
+
+ private
+
+ def translatable_models
+ Translatable.registry.select { |klass| klass.column_names.include?("team_id") }
+ end
+end
diff --git a/app/jobs/chat_response_job.rb b/app/jobs/chat_response_job.rb
new file mode 100644
index 0000000..6e18e7c
--- /dev/null
+++ b/app/jobs/chat_response_job.rb
@@ -0,0 +1,33 @@
+class ChatResponseJob < ApplicationJob
+ def perform(chat_id, content, attachment_paths = [])
+ chat = Chat.find(chat_id)
+
+ # Build the ask options
+ ask_options = {}
+ ask_options[:with] = attachment_paths if attachment_paths.present?
+
+ chat.ask(content, **ask_options) do |chunk|
+ if chunk.content && !chunk.content.blank?
+ message = chat.messages.last
+ message.broadcast_append_chunk(chunk.content)
+ end
+ end
+ ensure
+ # Clean up temporary files after processing
+ cleanup_temp_files(attachment_paths) if attachment_paths.present?
+ end
+
+ private
+
+ def cleanup_temp_files(paths)
+ paths.each do |path|
+ next unless File.exist?(path)
+ File.delete(path)
+ # Also remove the parent directory if empty
+ parent_dir = File.dirname(path)
+ FileUtils.rmdir(parent_dir) if Dir.empty?(parent_dir)
+ rescue StandardError => e
+ Rails.logger.warn "Failed to clean up temp file #{path}: #{e.message}"
+ end
+ end
+end
diff --git a/app/jobs/generate_success_story_image_job.rb b/app/jobs/generate_success_story_image_job.rb
index 08a3ed6..7285d73 100644
--- a/app/jobs/generate_success_story_image_job.rb
+++ b/app/jobs/generate_success_story_image_job.rb
@@ -2,29 +2,7 @@ class GenerateSuccessStoryImageJob < ApplicationJob
queue_as :default
def perform(post, force: false)
- Rails.logger.info "GenerateSuccessStoryImageJob started for post #{post.id}: force=#{force}, has_image=#{post.featured_image.attached?}"
-
- # Only process success stories with logos
- return unless post.success_story? && post.logo_svg.present?
-
- # Skip if already has a generated image (unless forced)
- should_regenerate = force || !post.featured_image.attached?
-
- unless should_regenerate
- Rails.logger.info "Skipping image generation for post #{post.id} - image already exists (force=#{force})"
- return
- end
-
- # Purge existing image if we're regenerating
- if force && post.featured_image.attached?
- Rails.logger.info "Purging existing image for post #{post.id} before regeneration"
- post.featured_image.purge
- end
-
- # Generate the image
- SuccessStoryImageGenerator.new(post).generate!
-
- Rails.logger.info "Generated success story image for post #{post.id}"
+ post.generate_og_image!(force: force)
rescue => e
Rails.logger.error "Failed to generate success story image for post #{post.id}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
diff --git a/app/jobs/generate_summary_job.rb b/app/jobs/generate_summary_job.rb
index 19c855f..0462cd4 100644
--- a/app/jobs/generate_summary_job.rb
+++ b/app/jobs/generate_summary_job.rb
@@ -2,250 +2,6 @@ class GenerateSummaryJob < ApplicationJob
queue_as :default
def perform(post, force: false)
- # Skip if summary exists and we're not forcing regeneration
- return if post.summary.present? && !force
-
- # Prepare the text and context for summarization
- text_to_summarize, context = prepare_text_with_context(post)
-
- # Skip if we couldn't get meaningful text
- if text_to_summarize.blank? || text_to_summarize.length < 50
- Rails.logger.warn "Insufficient content for summary generation for post #{post.id}"
- return
- end
-
- # Try different AI providers
- summary = nil
- error = nil
-
- # Try Anthropic first if available
- if anthropic_configured?
- summary = generate_with_anthropic(text_to_summarize, context)
- end
-
- # Fall back to OpenAI if Anthropic fails or is not configured
- if summary.blank? && openai_configured?
- summary = generate_with_openai(text_to_summarize, context)
- end
-
- if summary.present?
- # Clean up any meta-language and enforce length
- summary = clean_summary(summary)
- post.update!(summary: summary)
- broadcast_update(post)
- else
- Rails.logger.error "Failed to generate summary for post #{post.id}: No AI service available or all failed"
- end
- end
-
- private
-
- def prepare_text_with_context(post)
- if post.link?
- # For external links, fetch the actual content
- text = fetch_external_content(post.url)
-
- # If fetching failed, try to at least use title
- if text.blank?
- text = "Title: #{post.title}\nURL: #{post.url}"
- end
-
- context = {
- type: "external_link",
- title: post.title,
- url: post.url,
- domain: (URI.parse(post.url).host rescue nil)
- }
- else
- # For articles and success stories, use the actual content
- text = post.content
- # Remove markdown formatting for cleaner summaries
- text = ActionView::Base.full_sanitizer.sanitize(text)
- context = {
- type: post.post_type,
- title: post.title,
- has_code: post.content.include?("```")
- }
- end
-
- # Truncate to reasonable length
- text = text.to_s.truncate(6000)
-
- [ text, context ]
- end
-
- def fetch_external_content(url)
- begin
- Rails.logger.info "Fetching content from: #{url}"
-
- fetcher = MetadataFetcher.new(url,
- connection_timeout: 5,
- read_timeout: 5,
- retries: 1,
- allow_redirections: :safe
- )
-
- result = fetcher.fetch!
- return nil if result.blank? || result[:parsed].blank?
-
- # Try to get the main content
- content_parts = []
-
- # Add title
- content_parts << "Title: #{result[:title]}" if result[:title].present?
-
- # Add description
- content_parts << "Description: #{result[:description]}" if result[:description].present?
-
- # Get the main text content
- if result[:parsed].present?
- # Try to extract main content, removing navigation, ads, etc.
- main_content = extract_main_content(result[:parsed])
- content_parts << main_content if main_content.present?
- end
-
- # Fallback to meta description and raw text if needed
- if content_parts.length <= 2
- raw_text = result[:parsed].css("body").text.squish rescue nil
- content_parts << raw_text if raw_text.present?
- end
-
- content_parts.join("\n\n")
- rescue => e
- Rails.logger.error "Failed to fetch external content from #{url}: #{e.message}"
- nil
- end
- end
-
- def extract_main_content(parsed_doc)
- # Try common content selectors
- content_selectors = [
- "main", "article", '[role="main"]', ".content", "#content",
- ".post-content", ".entry-content", ".article-body"
- ]
-
- content_selectors.each do |selector|
- element = parsed_doc.at_css(selector)
- if element
- text = element.text.squish
- return text if text.length > 100
- end
- end
-
- # If no main content found, try paragraphs
- paragraphs = parsed_doc.css("p").map(&:text).reject(&:blank?)
- return paragraphs.join(" ") if paragraphs.any?
-
- nil
- end
-
- def anthropic_configured?
- Rails.application.credentials.dig(:anthropic, :api_key).present? ||
- Rails.application.credentials.dig(:anthropic, :access_token).present?
- end
-
- def openai_configured?
- Rails.application.credentials.dig(:openai, :api_key).present? ||
- Rails.application.credentials.dig(:openai, :access_token).present?
- end
-
- def generate_with_anthropic(text, context)
- api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence ||
- Rails.application.credentials.dig(:anthropic, :access_token)
-
- begin
- client = Anthropic::Client.new(
- api_key: api_key
- )
-
- system_prompt = build_system_prompt(context)
- user_prompt = build_user_prompt(text, context)
-
- response = client.messages(
- parameters: {
- model: "claude-3-haiku-20240307",
- max_tokens: 50,
- temperature: 0.3,
- system: system_prompt,
- messages: [
- {
- role: "user",
- content: user_prompt
- }
- ]
- }
- )
-
- response.dig("content", 0, "text")
- rescue => e
- Rails.logger.error "Anthropic API error: #{e.message}"
- nil
- end
- end
-
- def generate_with_openai(text, context)
- token = Rails.application.credentials.dig(:openai, :api_key).presence ||
- Rails.application.credentials.dig(:openai, :access_token)
-
- begin
- client = OpenAI::Client.new(
- access_token: token
- )
-
- system_prompt = build_system_prompt(context)
- user_prompt = build_user_prompt(text, context)
-
- response = client.chat(
- parameters: {
- model: "gpt-3.5-turbo",
- messages: [
- {
- role: "system",
- content: system_prompt
- },
- {
- role: "user",
- content: user_prompt
- }
- ],
- temperature: 0.3,
- max_tokens: 50
- }
- )
-
- response.dig("choices", 0, "message", "content")
- rescue => e
- Rails.logger.error "OpenAI API error: #{e.message}"
- nil
- end
- end
-
- def build_system_prompt(context)
- "Output ONLY a single teaser sentence. No preamble. Maximum 200 characters. Hook the reader with the most intriguing aspect."
- end
-
- def build_user_prompt(text, context)
- "Teaser:\n\n#{text}"
- end
-
- def clean_summary(summary)
- # Remove common meta-language prefixes
- cleaned = summary.gsub(/^(Here is a |Here's a |Here are |Teaser: |The teaser: |One-sentence teaser: )/i, "")
- cleaned = cleaned.gsub(/^(This article |This page |This resource |Learn about |Discover |Explore )/i, "")
-
- # Remove quotes if the entire summary is quoted
- cleaned = cleaned.gsub(/^["'](.+)["']$/, '\1')
-
- cleaned.strip
- end
-
- def broadcast_update(post)
- # Broadcast the summary update via Turbo Streams
- Turbo::StreamsChannel.broadcast_replace_to(
- "post_#{post.id}",
- target: "post_#{post.id}_summary",
- partial: "posts/summary",
- locals: { post: post }
- )
+ post.generate_summary!(force: force)
end
end
diff --git a/app/jobs/generate_testimonial_fields_job.rb b/app/jobs/generate_testimonial_fields_job.rb
deleted file mode 100644
index cf1db19..0000000
--- a/app/jobs/generate_testimonial_fields_job.rb
+++ /dev/null
@@ -1,181 +0,0 @@
-class GenerateTestimonialFieldsJob < ApplicationJob
- queue_as :default
-
- MAX_HEADING_RETRIES = 5
-
- def perform(testimonial)
- existing_headings = Testimonial.where.not(id: testimonial.id).where.not(heading: nil).pluck(:heading)
-
- user = testimonial.user
- user_context = [ user.display_name, user.bio, user.company ].compact_blank.join(", ")
-
- system_prompt = build_system_prompt(existing_headings)
- user_prompt = "User: #{user_context}\nQuote: #{testimonial.quote}"
-
- if testimonial.ai_feedback.present? && testimonial.ai_attempts > 0
- user_prompt += "\n\nPrevious feedback to address: #{testimonial.ai_feedback}"
- end
-
- parsed = generate_fields(system_prompt, user_prompt)
-
- unless parsed
- Rails.logger.error "Failed to generate testimonial fields for testimonial #{testimonial.id}"
- testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.")
- broadcast_update(testimonial)
- return
- end
-
- # If the heading collides, retry with the rejected heading added to the exclusion list
- retries = 0
- while heading_taken?(parsed["heading"], testimonial.id) && retries < MAX_HEADING_RETRIES
- retries += 1
- existing_headings << parsed["heading"]
- retry_prompt = build_system_prompt(existing_headings)
- parsed = generate_fields(retry_prompt, user_prompt)
- break unless parsed
- end
-
- unless parsed
- testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.")
- broadcast_update(testimonial)
- return
- end
-
- if heading_taken?(parsed["heading"], testimonial.id)
- Rails.logger.warn "Testimonial #{testimonial.id}: heading '#{parsed["heading"]}' still collides after #{MAX_HEADING_RETRIES} retries, saving anyway"
- end
-
- testimonial.update!(
- heading: parsed["heading"],
- subheading: parsed["subheading"],
- body_text: parsed["body_text"]
- )
- ValidateTestimonialJob.perform_later(testimonial)
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse AI response for testimonial #{testimonial.id}: #{e.message}"
- testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.")
- broadcast_update(testimonial)
- end
-
- private
-
- def build_system_prompt(existing_headings)
- taken = if existing_headings.any?
- "These headings are ALREADY TAKEN and must NOT be used (pick a synonym or related concept instead): #{existing_headings.join(', ')}."
- else
- "No headings are taken yet — pick any fitting word."
- end
-
- <<~PROMPT
- You generate structured testimonial content for a Ruby programming language advocacy site.
- Given a user's quote about why they love Ruby, generate:
-
- 1. heading: A unique 1-3 word heading that captures the THEME or FEELING of the quote.
- Be creative and specific. Go beyond generic words. Think of evocative nouns, metaphors, compound phrases, or poetic concepts.
- The heading must make sense as an answer to "Why Ruby?" — e.g. "Why Ruby?" → "Flow State", "Clarity", "Pure Joy".
- Good examples: "Spark", "Flow State", "Quiet Power", "Warm Glow", "First Love", "Playground", "Second Nature", "Deep Roots", "Readable Code", "Clean Slate", "Smooth Sailing", "Expressiveness", "Old Friend", "Sharp Tools", "Creative Freedom", "Solid Ground", "Calm Waters", "Poetic Logic", "Builder's Joy", "Sweet Spot", "Hidden Gem", "Fresh Start", "True North", "Clarity", "Belonging", "Empowerment", "Momentum", "Simplicity", "Trust", "Confidence"
- #{taken}
- 2. subheading: A short tagline under 10 words.
- 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea. Add new angles, examples, or implications.
- Do NOT repeat or paraphrase what the user already said. Build on top of it.
-
- WRITING STYLE — sound like a real person, not an AI:
- - NEVER use: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance
- - NEVER use inflated phrases: "serves as", "stands as", "is a testament to", "highlights the importance of", "reflects broader", "setting the stage"
- - NEVER use "It's not just X, it's Y" or "Not only X but also Y" parallelisms
- - NEVER use rule-of-three lists (e.g., "elegant, expressive, and powerful")
- - NEVER end with vague positivity ("the future looks bright", "exciting times ahead")
- - AVOID -ing tack-ons: "ensuring...", "highlighting...", "fostering..."
- - AVOID em dashes. Use commas or periods instead.
- - AVOID filler: "In order to", "It is important to note", "Due to the fact that"
- - USE simple verbs: "is", "has", "does" — not "serves as", "boasts", "features"
- - BE specific and concrete. Say what Ruby actually does, not how significant it is.
- - Write like a developer talking to a friend, not a press release.
-
- Respond with valid JSON only: {"heading": "...", "subheading": "...", "body_text": "..."}
- PROMPT
- end
-
- def generate_fields(system_prompt, user_prompt)
- result = nil
-
- if anthropic_configured?
- result = generate_with_anthropic(system_prompt, user_prompt)
- end
-
- if result.nil? && openai_configured?
- result = generate_with_openai(system_prompt, user_prompt)
- end
-
- result ? JSON.parse(result) : nil
- end
-
- def broadcast_update(testimonial)
- Turbo::StreamsChannel.broadcast_replace_to(
- "testimonial_#{testimonial.id}",
- target: "testimonial_section",
- partial: "testimonials/section",
- locals: { testimonial: testimonial, user: testimonial.user }
- )
- end
-
- def heading_taken?(heading, testimonial_id)
- Testimonial.where.not(id: testimonial_id).exists?(heading: heading)
- end
-
- def anthropic_configured?
- Rails.application.credentials.dig(:anthropic, :api_key).present? ||
- Rails.application.credentials.dig(:anthropic, :access_token).present?
- end
-
- def openai_configured?
- Rails.application.credentials.dig(:openai, :api_key).present? ||
- Rails.application.credentials.dig(:openai, :access_token).present?
- end
-
- def generate_with_anthropic(system_prompt, user_prompt)
- api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence ||
- Rails.application.credentials.dig(:anthropic, :access_token)
-
- client = Anthropic::Client.new(api_key: api_key)
-
- response = client.messages(
- parameters: {
- model: "claude-3-haiku-20240307",
- max_tokens: 300,
- temperature: 0.8,
- system: system_prompt,
- messages: [ { role: "user", content: user_prompt } ]
- }
- )
-
- response.dig("content", 0, "text")
- rescue => e
- Rails.logger.error "Anthropic API error in GenerateTestimonialFieldsJob: #{e.message}"
- nil
- end
-
- def generate_with_openai(system_prompt, user_prompt)
- token = Rails.application.credentials.dig(:openai, :api_key).presence ||
- Rails.application.credentials.dig(:openai, :access_token)
-
- client = OpenAI::Client.new(access_token: token)
-
- response = client.chat(
- parameters: {
- model: "gpt-3.5-turbo",
- messages: [
- { role: "system", content: system_prompt },
- { role: "user", content: user_prompt }
- ],
- temperature: 0.8,
- max_tokens: 300
- }
- )
-
- response.dig("choices", 0, "message", "content")
- rescue => e
- Rails.logger.error "OpenAI API error in GenerateTestimonialFieldsJob: #{e.message}"
- nil
- end
-end
diff --git a/app/jobs/generate_testimonial_job.rb b/app/jobs/generate_testimonial_job.rb
new file mode 100644
index 0000000..51e9b26
--- /dev/null
+++ b/app/jobs/generate_testimonial_job.rb
@@ -0,0 +1,7 @@
+class GenerateTestimonialJob < ApplicationJob
+ queue_as :default
+
+ def perform(testimonial)
+ testimonial.generate_ai_fields!
+ end
+end
diff --git a/app/jobs/normalize_location_job.rb b/app/jobs/normalize_location_job.rb
index 97a243f..ada1938 100644
--- a/app/jobs/normalize_location_job.rb
+++ b/app/jobs/normalize_location_job.rb
@@ -5,20 +5,6 @@ class NormalizeLocationJob < ApplicationJob
def perform(user_id)
user = User.find_by(id: user_id)
- return unless user
-
- result = LocationNormalizer.normalize(user.location)
-
- if result
- timezone = TimezoneResolver.resolve(result[:latitude], result[:longitude])
- user.update_columns(
- normalized_location: result[:normalized_location],
- latitude: result[:latitude],
- longitude: result[:longitude],
- timezone: timezone
- )
- else
- user.update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil)
- end
+ user&.geocode!
end
end
diff --git a/app/jobs/translate_content_job.rb b/app/jobs/translate_content_job.rb
new file mode 100644
index 0000000..7466b20
--- /dev/null
+++ b/app/jobs/translate_content_job.rb
@@ -0,0 +1,85 @@
+class TranslateContentJob < ApplicationJob
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
+
+ def perform(model_class_name, record_id, source_locale, target_locale)
+ record = model_class_name.constantize.find_by(id: record_id)
+ return unless record
+
+ attributes = record.class.translatable_attributes
+ return if attributes.empty?
+
+ # Collect source content
+ source_content = {}
+ Mobility.with_locale(source_locale) do
+ attributes.each do |attr|
+ value = record.send(attr)
+ source_content[attr] = value if value.present?
+ end
+ end
+
+ return if source_content.empty?
+
+ # Skip if all translations already exist for this locale
+ if translations_exist?(record, target_locale, source_content.keys)
+ return
+ end
+
+ # Build translation prompt
+ target_language = Language.find_by(code: target_locale)&.name || target_locale
+ prompt = build_prompt(source_content, target_language)
+
+ # Call LLM for translation
+ response = RubyLLM.chat(model: Setting.get(:translation_model, default: Setting::DEFAULT_AI_MODEL)).ask(prompt)
+ translated = parse_response(response.content, source_content.keys)
+
+ return unless translated
+
+ # Save translations
+ record.skip_translation_callbacks = true
+ Mobility.with_locale(target_locale) do
+ translated.each do |attr, value|
+ record.send("#{attr}=", value)
+ end
+ record.save!
+ end
+ ensure
+ record&.skip_translation_callbacks = false if record
+ end
+
+ private
+
+ def translations_exist?(record, locale, attributes)
+ conditions = { translatable_type: record.class.name, translatable_id: record.id, locale: locale.to_s, key: attributes.map(&:to_s) }
+ existing_keys = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.where(conditions).pluck(:key) |
+ Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.where(conditions).pluck(:key)
+ (attributes.map(&:to_s) - existing_keys).empty?
+ end
+
+ def build_prompt(content, target_language)
+ json_content = content.to_json
+ <<~PROMPT
+ Translate the following JSON values to #{target_language}. Keep the JSON keys unchanged. Respond with only valid JSON, no other text.
+
+ #{json_content}
+ PROMPT
+ end
+
+ def parse_response(response_text, expected_keys)
+ # Extract JSON from response (handle possible markdown code blocks)
+ json_str = response_text.strip
+ json_str = json_str.gsub(/\A```(?:json)?\n?/, "").gsub(/\n?```\z/, "")
+
+ parsed = JSON.parse(json_str)
+ result = {}
+
+ expected_keys.each do |key|
+ value = parsed[key] || parsed[key.to_s]
+ result[key] = value if value.present?
+ end
+
+ result.presence
+ rescue JSON::ParserError => e
+ Rails.logger.warn "TranslateContentJob: Failed to parse LLM response: #{e.message}"
+ nil
+ end
+end
diff --git a/app/jobs/update_github_data_job.rb b/app/jobs/update_github_data_job.rb
index 91c2482..f9acb25 100644
--- a/app/jobs/update_github_data_job.rb
+++ b/app/jobs/update_github_data_job.rb
@@ -1,7 +1,7 @@
class UpdateGithubDataJob < ApplicationJob
queue_as :default
- BATCH_SIZE = 5 # Server-side Ruby filtering means fewer repos returned
+ BATCH_SIZE = 5
def perform
Rails.logger.info "Starting GitHub data update using GraphQL batch fetching..."
@@ -11,17 +11,12 @@ def perform
all_errors = []
User.where.not(username: [ nil, "" ]).find_in_batches(batch_size: BATCH_SIZE) do |batch|
- Rails.logger.info "Processing batch of #{batch.size} users..."
-
- results = GithubDataFetcher.batch_fetch_and_update!(batch)
+ results = User.batch_sync_github_data!(batch)
total_updated += results[:updated]
total_failed += results[:failed]
all_errors.concat(results[:errors]) if results[:errors].present?
- Rails.logger.info "Batch complete: #{results[:updated]} updated, #{results[:failed]} failed"
-
- # Brief pause between batches to be respectful of API
sleep 0.5
end
diff --git a/app/jobs/validate_testimonial_job.rb b/app/jobs/validate_testimonial_job.rb
index 4ccda7c..ba19183 100644
--- a/app/jobs/validate_testimonial_job.rb
+++ b/app/jobs/validate_testimonial_job.rb
@@ -1,169 +1,7 @@
class ValidateTestimonialJob < ApplicationJob
queue_as :default
- MAX_ATTEMPTS = 3
-
def perform(testimonial)
- existing = Testimonial.published.where.not(id: testimonial.id)
- .pluck(:heading, :quote)
- .map { |h, q| "Heading: #{h}, Quote: #{q}" }
- .join("\n")
-
- system_prompt = <<~PROMPT
- You validate testimonials for a Ruby programming language advocacy site.
-
- CONTENT POLICY:
- - Hate speech, slurs, personal attacks, or targeted insults toward individuals or groups are NEVER allowed.
- - Casual expletives used positively (e.g., "Damn, Ruby is amazing!" or "Fuck, I love this language!") are ALLOWED.
- - The key distinction: profanity expressing enthusiasm = OK. Profanity attacking or demeaning people/groups = NOT OK.
- - The quote MUST express genuine love or appreciation for Ruby. This is an advocacy site — negative, dismissive, sarcastic, or trolling sentiments about Ruby are NOT allowed.
-
- VALIDATION RULES:
- 1. First check the user's QUOTE against the content policy. If it violates (including being negative about Ruby), reject immediately with reject_reason "quote".
- 2. If the quote is fine, check the AI-generated fields (heading/subheading/body). ONLY reject generation if there is a CLEAR problem:
- - The body contradicts or misrepresents the quote
- - The subheading is nonsensical or unrelated
- - The content is factually wrong about Ruby
- Do NOT reject for duplicate headings (handled elsewhere). Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it.
- 3. If everything looks acceptable, publish it.
-
- AI-SOUNDING LANGUAGE CHECK:
- Reject with reason "generation" if the generated heading/subheading/body contains:
- - Words: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance
- - Patterns: "serves as", "stands as", "is a testament to", "not just X, it's Y", "not only X but also Y"
- - Rule-of-three adjective/noun lists
- - Vague positive endings ("the future looks bright", "exciting times ahead")
- - Superficial -ing tack-ons ("ensuring...", "highlighting...", "fostering...")
- If the quote itself is fine but the generated text sounds like AI wrote it, set reject_reason to "generation" and explain which phrases sound artificial.
-
- Existing published testimonials (for context):
- #{existing.presence || "None yet."}
-
- Respond with valid JSON only: {"publish": true/false, "reject_reason": "quote" or "generation" or null, "feedback": "..."}
- - reject_reason "quote": the user's quote violates content policy or is not meaningful. Feedback should tell the USER what to fix.
- - reject_reason "generation": quote is fine but generated fields have a specific problem. Feedback must be a SPECIFIC INSTRUCTION for the AI generator, e.g., "The heading 'X' is already taken, use a different word" or "The body contradicts the quote by saying Y when the user said Z". Be concrete.
- - reject_reason null: publishing. Feedback should be a short positive note for the user.
- PROMPT
-
- user_prompt = <<~PROMPT
- Quote: #{testimonial.quote}
- Generated heading: #{testimonial.heading}
- Generated subheading: #{testimonial.subheading}
- Generated body: #{testimonial.body_text}
- PROMPT
-
- result = nil
-
- if anthropic_configured?
- result = generate_with_anthropic(system_prompt, user_prompt)
- end
-
- if result.nil? && openai_configured?
- result = generate_with_openai(system_prompt, user_prompt)
- end
-
- if result
- parsed = JSON.parse(result)
-
- if parsed["publish"]
- testimonial.update!(published: true, ai_feedback: parsed["feedback"], reject_reason: nil)
- elsif parsed["reject_reason"] == "quote"
- testimonial.update!(
- published: false,
- ai_feedback: parsed["feedback"],
- reject_reason: "quote"
- )
- elsif testimonial.ai_attempts < MAX_ATTEMPTS
- testimonial.update!(
- ai_attempts: testimonial.ai_attempts + 1,
- ai_feedback: parsed["feedback"],
- reject_reason: "generation",
- published: false
- )
- GenerateTestimonialFieldsJob.perform_later(testimonial)
- else
- testimonial.update!(
- published: false,
- ai_feedback: parsed["feedback"],
- reject_reason: "generation"
- )
- end
- else
- Rails.logger.error "Failed to validate testimonial #{testimonial.id}"
- testimonial.update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.")
- end
-
- broadcast_update(testimonial)
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse validation response for testimonial #{testimonial.id}: #{e.message}"
- testimonial.update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.")
- broadcast_update(testimonial)
- end
-
- private
-
- def anthropic_configured?
- Rails.application.credentials.dig(:anthropic, :api_key).present? ||
- Rails.application.credentials.dig(:anthropic, :access_token).present?
- end
-
- def openai_configured?
- Rails.application.credentials.dig(:openai, :api_key).present? ||
- Rails.application.credentials.dig(:openai, :access_token).present?
- end
-
- def generate_with_anthropic(system_prompt, user_prompt)
- api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence ||
- Rails.application.credentials.dig(:anthropic, :access_token)
-
- client = Anthropic::Client.new(api_key: api_key)
-
- response = client.messages(
- parameters: {
- model: "claude-3-haiku-20240307",
- max_tokens: 300,
- temperature: 0.3,
- system: system_prompt,
- messages: [ { role: "user", content: user_prompt } ]
- }
- )
-
- response.dig("content", 0, "text")
- rescue => e
- Rails.logger.error "Anthropic API error in ValidateTestimonialJob: #{e.message}"
- nil
- end
-
- def generate_with_openai(system_prompt, user_prompt)
- token = Rails.application.credentials.dig(:openai, :api_key).presence ||
- Rails.application.credentials.dig(:openai, :access_token)
-
- client = OpenAI::Client.new(access_token: token)
-
- response = client.chat(
- parameters: {
- model: "gpt-3.5-turbo",
- messages: [
- { role: "system", content: system_prompt },
- { role: "user", content: user_prompt }
- ],
- temperature: 0.3,
- max_tokens: 300
- }
- )
-
- response.dig("choices", 0, "message", "content")
- rescue => e
- Rails.logger.error "OpenAI API error in ValidateTestimonialJob: #{e.message}"
- nil
- end
-
- def broadcast_update(testimonial)
- Turbo::StreamsChannel.broadcast_replace_to(
- "testimonial_#{testimonial.id}",
- target: "testimonial_section",
- partial: "testimonials/section",
- locals: { testimonial: testimonial, user: testimonial.user }
- )
+ testimonial.validate_with_ai!
end
end
diff --git a/app/madmin/fields/gravatar_field.rb b/app/madmin/fields/gravatar_field.rb
new file mode 100644
index 0000000..5614a26
--- /dev/null
+++ b/app/madmin/fields/gravatar_field.rb
@@ -0,0 +1,8 @@
+class GravatarField < Madmin::Field
+ def gravatar_url(size: 40)
+ return nil unless value.present?
+
+ hash = Digest::MD5.hexdigest(value.downcase.strip)
+ "https://www.gravatar.com/avatar/#{hash}?s=#{size}&d=mp"
+ end
+end
diff --git a/app/madmin/fields/json_field.rb b/app/madmin/fields/json_field.rb
new file mode 100644
index 0000000..b6bb34a
--- /dev/null
+++ b/app/madmin/fields/json_field.rb
@@ -0,0 +1,12 @@
+class JsonField < Madmin::Field
+ def formatted_json(record)
+ val = value(record)
+ if val.present?
+ JSON.pretty_generate(val)
+ else
+ "{}"
+ end
+ rescue JSON::GeneratorError
+ val.to_s
+ end
+end
diff --git a/app/madmin/resources/active_storage/attachment_resource.rb b/app/madmin/resources/active_storage/attachment_resource.rb
new file mode 100644
index 0000000..a683031
--- /dev/null
+++ b/app/madmin/resources/active_storage/attachment_resource.rb
@@ -0,0 +1,29 @@
+class ActiveStorage::AttachmentResource < Madmin::Resource
+ # Menu configuration - nest under "Active Storage"
+ menu parent: "Active Storage", position: 1
+
+ # Attributes
+ attribute :id, form: false
+ attribute :name
+ attribute :created_at, form: false
+
+ # Associations
+ attribute :record
+ attribute :blob
+
+ # Add scopes to easily filter records
+ # scope :published
+
+ # Add actions to the resource's show page
+ # member_action do |record|
+ # link_to "Do Something", some_path
+ # end
+
+ # Customize the display name of records in the admin area.
+ # def self.display_name(record) = record.name
+
+ # Customize the default sort column and direction.
+ # def self.default_sort_column = "created_at"
+ #
+ # def self.default_sort_direction = "desc"
+end
diff --git a/app/madmin/resources/active_storage/blob_resource.rb b/app/madmin/resources/active_storage/blob_resource.rb
new file mode 100644
index 0000000..fa6dbeb
--- /dev/null
+++ b/app/madmin/resources/active_storage/blob_resource.rb
@@ -0,0 +1,38 @@
+class ActiveStorage::BlobResource < Madmin::Resource
+ # Menu configuration - nest under "Active Storage"
+ menu parent: "Active Storage", position: 2
+
+ # Attributes
+ attribute :id, form: false
+ attribute :key
+ attribute :filename
+ attribute :content_type
+ attribute :service_name
+ attribute :byte_size
+ attribute :checksum
+ attribute :created_at, form: false
+ attribute :analyzed
+ attribute :identified
+ attribute :composed
+ attribute :preview_image, index: false
+
+ # Associations
+ attribute :attachments
+ attribute :variant_records
+
+ # Add scopes to easily filter records
+ # scope :published
+
+ # Add actions to the resource's show page
+ # member_action do |record|
+ # link_to "Do Something", some_path
+ # end
+
+ # Customize the display name of records in the admin area.
+ # def self.display_name(record) = record.name
+
+ # Customize the default sort column and direction.
+ # def self.default_sort_column = "created_at"
+ #
+ # def self.default_sort_direction = "desc"
+end
diff --git a/app/madmin/resources/active_storage/variant_record_resource.rb b/app/madmin/resources/active_storage/variant_record_resource.rb
new file mode 100644
index 0000000..b100fdd
--- /dev/null
+++ b/app/madmin/resources/active_storage/variant_record_resource.rb
@@ -0,0 +1,29 @@
+class ActiveStorage::VariantRecordResource < Madmin::Resource
+ # Menu configuration - nest under "Active Storage"
+ menu parent: "Active Storage", position: 3
+
+ # Attributes
+ attribute :id, form: false
+ attribute :variation, index: false, show: false
+ attribute :variation_confirmation, index: false, show: false
+ attribute :image, index: false
+
+ # Associations
+ attribute :blob
+
+ # Add scopes to easily filter records
+ # scope :published
+
+ # Add actions to the resource's show page
+ # member_action do |record|
+ # link_to "Do Something", some_path
+ # end
+
+ # Customize the display name of records in the admin area.
+ # def self.display_name(record) = record.name
+
+ # Customize the default sort column and direction.
+ # def self.default_sort_column = "created_at"
+ #
+ # def self.default_sort_direction = "desc"
+end
diff --git a/app/madmin/resources/admin_resource.rb b/app/madmin/resources/admin_resource.rb
new file mode 100644
index 0000000..6041c44
--- /dev/null
+++ b/app/madmin/resources/admin_resource.rb
@@ -0,0 +1,27 @@
+class AdminResource < Madmin::Resource
+ # Attributes
+ attribute :id, form: false, index: false
+ attribute :email, field: GravatarField, index: true, show: true, form: false
+ attribute :email # Regular field for editing
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ # Associations
+
+ # Member action for sending magic link
+ member_action do |record|
+ button_to "Send Magic Link",
+ "/madmin/admins/#{record.id}/send_magic_link",
+ method: :post,
+ data: { turbo_confirm: "Send magic link to #{record.email}?" },
+ class: "btn btn-primary"
+ end
+
+ def self.searchable_attributes
+ [ :email ]
+ end
+
+ def self.display_name(record)
+ record.email
+ end
+end
diff --git a/app/madmin/resources/category_resource.rb b/app/madmin/resources/category_resource.rb
new file mode 100644
index 0000000..46862f6
--- /dev/null
+++ b/app/madmin/resources/category_resource.rb
@@ -0,0 +1,22 @@
+class CategoryResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit, :update ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :name
+ attribute :slug, form: false
+ attribute :description
+ attribute :position
+ attribute :is_success_story
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.searchable_attributes
+ [ :name ]
+ end
+
+ def self.display_name(record)
+ record.name
+ end
+end
diff --git a/app/madmin/resources/chat_resource.rb b/app/madmin/resources/chat_resource.rb
new file mode 100644
index 0000000..5366981
--- /dev/null
+++ b/app/madmin/resources/chat_resource.rb
@@ -0,0 +1,24 @@
+class ChatResource < Madmin::Resource
+ # Read-only resource
+ def self.actions
+ [ :index, :show ]
+ end
+
+ # Attributes
+ attribute :id, form: false, index: false
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ # Associations
+ attribute :user
+ attribute :model
+ attribute :messages
+
+ def self.searchable_attributes
+ [] # Custom search in controller
+ end
+
+ def self.display_name(record)
+ "Chat with #{record.user.email}" if record.user
+ end
+end
diff --git a/app/madmin/resources/comment_resource.rb b/app/madmin/resources/comment_resource.rb
new file mode 100644
index 0000000..ac66dfb
--- /dev/null
+++ b/app/madmin/resources/comment_resource.rb
@@ -0,0 +1,21 @@
+class CommentResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit, :update ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :body
+ attribute :published
+ attribute :user
+ attribute :post
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.searchable_attributes
+ [ :body ]
+ end
+
+ def self.display_name(record)
+ record.body.truncate(60)
+ end
+end
diff --git a/app/madmin/resources/language_resource.rb b/app/madmin/resources/language_resource.rb
new file mode 100644
index 0000000..5d27cff
--- /dev/null
+++ b/app/madmin/resources/language_resource.rb
@@ -0,0 +1,29 @@
+class LanguageResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit ]
+ end
+
+ attribute :id, form: false
+ attribute :code, form: false, index: true
+ attribute :name, form: false, index: true
+ attribute :native_name, form: false, index: true
+ attribute :enabled, index: true
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.index_attributes
+ [ :id, :code, :name, :native_name, :enabled ]
+ end
+
+ def self.form_attributes
+ [ :enabled ]
+ end
+
+ def self.searchable_attributes
+ [ :code, :name, :native_name ]
+ end
+
+ def self.display_name(record)
+ "#{record.name} (#{record.code})"
+ end
+end
diff --git a/app/madmin/resources/message_resource.rb b/app/madmin/resources/message_resource.rb
new file mode 100644
index 0000000..c4e6b7c
--- /dev/null
+++ b/app/madmin/resources/message_resource.rb
@@ -0,0 +1,33 @@
+class MessageResource < Madmin::Resource
+ # Read-only resource
+ def self.actions
+ [ :index, :show ]
+ end
+
+ # Attributes
+ attribute :id, form: false, index: false
+ attribute :role
+ attribute :content, :text
+ attribute :chat
+ attribute :model
+ attribute :input_tokens
+ attribute :output_tokens
+ attribute :cached_tokens
+ attribute :cache_creation_tokens
+ attribute :cost
+ attribute :content_raw, field: JsonField
+ attribute :tool_calls
+ attribute :created_at
+ attribute :updated_at
+
+ # Associations
+
+ def self.searchable_attributes
+ [ :content ]
+ end
+
+ def self.display_name(record)
+ truncated = record.content.to_s.truncate(50)
+ "#{record.role}: #{truncated}"
+ end
+end
diff --git a/app/madmin/resources/model_resource.rb b/app/madmin/resources/model_resource.rb
new file mode 100644
index 0000000..0c8c82a
--- /dev/null
+++ b/app/madmin/resources/model_resource.rb
@@ -0,0 +1,28 @@
+class ModelResource < Madmin::Resource
+ # Attributes
+ attribute :id, form: false, index: false
+ attribute :name
+ attribute :model_id
+ attribute :provider, :select, collection: [ "openai", "anthropic" ]
+ attribute :family
+ attribute :context_window
+ attribute :max_output_tokens
+ attribute :knowledge_cutoff
+ attribute :modalities, field: JsonField, form: false
+ attribute :capabilities, field: JsonField, form: false
+ attribute :pricing, field: JsonField, form: false
+ attribute :metadata, field: JsonField, form: false
+ attribute :model_created_at, form: false
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ # Associations
+
+ def self.searchable_attributes
+ [ :name, :model_id, :provider ]
+ end
+
+ def self.display_name(record)
+ record.name
+ end
+end
diff --git a/app/madmin/resources/post_resource.rb b/app/madmin/resources/post_resource.rb
new file mode 100644
index 0000000..4338380
--- /dev/null
+++ b/app/madmin/resources/post_resource.rb
@@ -0,0 +1,32 @@
+class PostResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit, :update ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :title
+ attribute :post_type
+ attribute :published
+ attribute :needs_admin_review
+ attribute :pin_position
+ attribute :url
+ attribute :summary, form: false
+ attribute :user
+ attribute :category
+ attribute :comments_count, form: false
+ attribute :reports_count, form: false
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.sortable_columns
+ super + %w[comments_count reports_count]
+ end
+
+ def self.searchable_attributes
+ [ :title, :url ]
+ end
+
+ def self.display_name(record)
+ record.title.truncate(60)
+ end
+end
diff --git a/app/madmin/resources/project_resource.rb b/app/madmin/resources/project_resource.rb
new file mode 100644
index 0000000..cc67614
--- /dev/null
+++ b/app/madmin/resources/project_resource.rb
@@ -0,0 +1,30 @@
+class ProjectResource < Madmin::Resource
+ def self.actions
+ [ :index, :show ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :name
+ attribute :github_url
+ attribute :description
+ attribute :stars
+ attribute :forks_count
+ attribute :hidden
+ attribute :archived
+ attribute :user
+ attribute :pushed_at
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.sortable_columns
+ super + %w[stars forks_count]
+ end
+
+ def self.searchable_attributes
+ [ :name, :github_url ]
+ end
+
+ def self.display_name(record)
+ record.name
+ end
+end
diff --git a/app/madmin/resources/report_resource.rb b/app/madmin/resources/report_resource.rb
new file mode 100644
index 0000000..a7a6cdc
--- /dev/null
+++ b/app/madmin/resources/report_resource.rb
@@ -0,0 +1,21 @@
+class ReportResource < Madmin::Resource
+ def self.actions
+ [ :index, :show ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :reason
+ attribute :description
+ attribute :user
+ attribute :post
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.searchable_attributes
+ [ :description ]
+ end
+
+ def self.display_name(record)
+ "#{record.reason.titleize} report on #{record.post.title.truncate(40)}"
+ end
+end
diff --git a/app/madmin/resources/tag_resource.rb b/app/madmin/resources/tag_resource.rb
new file mode 100644
index 0000000..06fecb5
--- /dev/null
+++ b/app/madmin/resources/tag_resource.rb
@@ -0,0 +1,19 @@
+class TagResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit, :update ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :name
+ attribute :slug, form: false
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.searchable_attributes
+ [ :name ]
+ end
+
+ def self.display_name(record)
+ record.name
+ end
+end
diff --git a/app/madmin/resources/team_resource.rb b/app/madmin/resources/team_resource.rb
new file mode 100644
index 0000000..73b6f30
--- /dev/null
+++ b/app/madmin/resources/team_resource.rb
@@ -0,0 +1,40 @@
+class TeamResource < Madmin::Resource
+ # Read-only: teams are managed through the user interface
+ def self.actions
+ [ :index, :show ]
+ end
+
+ def self.model_find(id)
+ model.find_by!(slug: id)
+ end
+
+ # Attributes
+ attribute :id, form: false
+ attribute :name
+ attribute :slug
+ attribute :api_key
+ attribute :stripe_customer_id, form: false
+ attribute :subscription_status, form: false
+ attribute :current_period_ends_at, form: false
+ attribute :created_at, form: false
+
+ # Associations
+ attribute :memberships
+ attribute :chats
+
+ def self.index_attributes
+ [ :id, :name, :slug, :created_at ]
+ end
+
+ def self.sortable_columns
+ super + %w[owner_name members_count chats_count total_cost]
+ end
+
+ def self.searchable_attributes
+ [ :name, :slug ]
+ end
+
+ def self.display_name(record)
+ record.name
+ end
+end
diff --git a/app/madmin/resources/testimonial_resource.rb b/app/madmin/resources/testimonial_resource.rb
new file mode 100644
index 0000000..81a7129
--- /dev/null
+++ b/app/madmin/resources/testimonial_resource.rb
@@ -0,0 +1,27 @@
+class TestimonialResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit, :update ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :quote
+ attribute :heading, form: false
+ attribute :subheading, form: false
+ attribute :body_text, form: false
+ attribute :published
+ attribute :position
+ attribute :ai_attempts, form: false
+ attribute :ai_feedback, form: false
+ attribute :reject_reason, form: false
+ attribute :user
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.searchable_attributes
+ [ :quote, :heading ]
+ end
+
+ def self.display_name(record)
+ record.heading.presence || record.quote&.truncate(60) || "Testimonial ##{record.id.first(8)}"
+ end
+end
diff --git a/app/madmin/resources/tool_call_resource.rb b/app/madmin/resources/tool_call_resource.rb
new file mode 100644
index 0000000..67fb2bb
--- /dev/null
+++ b/app/madmin/resources/tool_call_resource.rb
@@ -0,0 +1,20 @@
+class ToolCallResource < Madmin::Resource
+ # Attributes
+ attribute :id, form: false, index: false
+ attribute :name
+ attribute :tool_call_id
+ attribute :message
+ attribute :arguments, field: JsonField, form: false
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ # Associations
+
+ def self.searchable_attributes
+ [ :name, :tool_call_id ]
+ end
+
+ def self.display_name(record)
+ record.name
+ end
+end
diff --git a/app/madmin/resources/user_resource.rb b/app/madmin/resources/user_resource.rb
new file mode 100644
index 0000000..e190434
--- /dev/null
+++ b/app/madmin/resources/user_resource.rb
@@ -0,0 +1,29 @@
+class UserResource < Madmin::Resource
+ def self.actions
+ [ :index, :show, :edit, :update ]
+ end
+
+ attribute :id, form: false, index: false
+ attribute :username
+ attribute :name
+ attribute :email
+ attribute :role
+ attribute :location
+ attribute :published_posts_count, form: false
+ attribute :published_comments_count, form: false
+ attribute :github_stars_sum, form: false
+ attribute :created_at, form: false
+ attribute :updated_at, form: false
+
+ def self.sortable_columns
+ super + %w[published_posts_count published_comments_count github_stars_sum]
+ end
+
+ def self.searchable_attributes
+ [ :username, :name, :email ]
+ end
+
+ def self.display_name(record)
+ record.display_name
+ end
+end
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
new file mode 100644
index 0000000..c667e3e
--- /dev/null
+++ b/app/mailers/admin_mailer.rb
@@ -0,0 +1,12 @@
+class AdminMailer < ApplicationMailer
+ def magic_link(admin)
+ @admin = admin
+ @token = admin.generate_magic_link_token
+ @magic_link_url = admins_verify_magic_link_url(token: @token)
+
+ mail(
+ to: @admin.email,
+ subject: t(".subject")
+ )
+ end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
new file mode 100644
index 0000000..7bee9e7
--- /dev/null
+++ b/app/mailers/user_mailer.rb
@@ -0,0 +1,24 @@
+class UserMailer < ApplicationMailer
+ def magic_link(user)
+ @user = user
+ @token = user.generate_magic_link_token
+ @magic_link_url = verify_magic_link_url(token: @token)
+
+ mail(
+ to: @user.email,
+ subject: t(".subject")
+ )
+ end
+
+ def team_invitation(user, team, invited_by, invite_url)
+ @user = user
+ @team = team
+ @invited_by = invited_by
+ @invite_url = invite_url
+
+ mail(
+ to: @user.email,
+ subject: t(".subject", team_name: @team.name)
+ )
+ end
+end
diff --git a/app/models/admin.rb b/app/models/admin.rb
new file mode 100644
index 0000000..08ce5a6
--- /dev/null
+++ b/app/models/admin.rb
@@ -0,0 +1,7 @@
+class Admin < ApplicationRecord
+ validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
+
+ def generate_magic_link_token
+ signed_id(purpose: :magic_link, expires_in: 15.minutes)
+ end
+end
diff --git a/app/models/article.rb b/app/models/article.rb
new file mode 100644
index 0000000..51c49d4
--- /dev/null
+++ b/app/models/article.rb
@@ -0,0 +1,13 @@
+class Article < ApplicationRecord
+ include Translatable
+
+ belongs_to :team
+ belongs_to :user
+
+ translatable :title, type: :string
+ translatable :body, type: :text
+
+ validates :title, presence: true
+
+ scope :recent, -> { order(created_at: :desc) }
+end
diff --git a/app/models/chat.rb b/app/models/chat.rb
new file mode 100644
index 0000000..0e1fc6f
--- /dev/null
+++ b/app/models/chat.rb
@@ -0,0 +1,27 @@
+class Chat < ApplicationRecord
+ include Costable
+
+ belongs_to :user
+ belongs_to :team, optional: true
+ belongs_to :model, optional: true
+ acts_as_chat messages_foreign_key: :chat_id
+
+ scope :chronologically, -> { order(created_at: :asc) }
+ scope :recent, -> { order(created_at: :desc) }
+ scope :conversations, -> { where(purpose: "conversation") }
+ scope :system, -> { where.not(purpose: "conversation") }
+
+ after_destroy :update_costs_on_destroy
+
+ # Recalculate total cost from messages
+ def recalculate_total_cost!
+ update_column(:total_cost, messages.sum(:cost))
+ end
+
+ private
+
+ def update_costs_on_destroy
+ user&.recalculate_total_cost!
+ model&.recalculate_total_cost!
+ end
+end
diff --git a/app/models/concerns/costable.rb b/app/models/concerns/costable.rb
new file mode 100644
index 0000000..a8ea95b
--- /dev/null
+++ b/app/models/concerns/costable.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# Shared cost formatting and calculation for models with total_cost column
+module Costable
+ extend ActiveSupport::Concern
+
+ # Format total cost for display (e.g., "$0.0012" or "<$0.0001")
+ def formatted_total_cost
+ cost = read_attribute(:total_cost) || 0
+ return nil if cost.zero?
+
+ if cost < 0.0001
+ "<$0.0001"
+ else
+ "$#{'%.4f' % cost}"
+ end
+ end
+end
diff --git a/app/models/concerns/post/ai_summarizable.rb b/app/models/concerns/post/ai_summarizable.rb
new file mode 100644
index 0000000..70f587d
--- /dev/null
+++ b/app/models/concerns/post/ai_summarizable.rb
@@ -0,0 +1,58 @@
+# app/models/concerns/post/ai_summarizable.rb
+module Post::AiSummarizable
+ extend ActiveSupport::Concern
+
+ def generate_summary!(force: false)
+ return if summary.present? && !force
+
+ text_to_summarize = prepare_text_for_summary
+ return if text_to_summarize.blank? || text_to_summarize.length < 50
+
+ chat = user.chats.create!(
+ purpose: "summary",
+ model: Model.find_by(model_id: Setting.get(:summary_model, default: Setting::DEFAULT_AI_MODEL))
+ )
+
+ prompt = "Output ONLY a single teaser sentence. No preamble. Maximum 200 characters. Hook the reader with the most intriguing aspect.\n\nTeaser:\n\n#{text_to_summarize}"
+
+ response = chat.ask(prompt)
+ raw_summary = response.content
+
+ return unless raw_summary.present?
+
+ cleaned = clean_ai_summary(raw_summary)
+ update!(summary: cleaned)
+ broadcast_summary_update
+ rescue => e
+ Rails.logger.error "Failed to generate summary for post #{id}: #{e.message}"
+ end
+
+ private
+
+ def prepare_text_for_summary
+ if link?
+ text = fetch_external_content
+ text = "Title: #{title}\nURL: #{url}" if text.blank?
+ else
+ text = ActionView::Base.full_sanitizer.sanitize(content)
+ end
+
+ text.to_s.truncate(6000)
+ end
+
+ def clean_ai_summary(raw)
+ cleaned = raw.gsub(/^(Here is a |Here's a |Here are |Teaser: |The teaser: |One-sentence teaser: )/i, "")
+ cleaned = cleaned.gsub(/^(This article |This page |This resource |Learn about |Discover |Explore )/i, "")
+ cleaned = cleaned.gsub(/^["'](.+)["']$/, '\1')
+ cleaned.strip
+ end
+
+ def broadcast_summary_update
+ Turbo::StreamsChannel.broadcast_replace_to(
+ "post_#{id}",
+ target: "post_#{id}_summary",
+ partial: "posts/summary",
+ locals: { post: self }
+ )
+ end
+end
diff --git a/app/models/concerns/post/image_variantable.rb b/app/models/concerns/post/image_variantable.rb
new file mode 100644
index 0000000..f813ff2
--- /dev/null
+++ b/app/models/concerns/post/image_variantable.rb
@@ -0,0 +1,167 @@
+# app/models/concerns/post/image_variantable.rb
+module Post::ImageVariantable
+ extend ActiveSupport::Concern
+
+ ALLOWED_CONTENT_TYPES = %w[
+ image/jpeg image/jpg image/png image/webp image/tiff image/x-tiff
+ ].freeze
+
+ IMAGE_VARIANTS = {
+ tile: { width: 684, height: 384, quality: 92 },
+ post: { width: 1664, height: 936, quality: 94 },
+ og: { width: 1200, height: 630, quality: 95 }
+ }.freeze
+
+ MAX_IMAGE_SIZE = 20.megabytes
+
+ # Generate all WebP variants from featured_image
+ def process_image_variants!
+ return { error: "No image attached" } unless featured_image.attached?
+ return { error: "File too large" } if featured_image.blob.byte_size > MAX_IMAGE_SIZE
+ return { error: "Invalid file type" } unless ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type)
+
+ variants = {}
+
+ featured_image.blob.open do |tempfile|
+ IMAGE_VARIANTS.each do |name, config|
+ variant_blob = generate_image_variant(tempfile.path, config)
+ variants[name] = variant_blob.id if variant_blob
+ end
+ end
+
+ update_columns(image_variants: variants)
+ { success: true, variants: variants }
+ rescue => e
+ Rails.logger.error "Image processing error: #{e.message}"
+ { error: "Processing failed: #{e.message}" }
+ end
+
+ def image_variant(size = :medium)
+ return nil unless featured_image.attached? && image_variants.present?
+
+ variant_id = image_variants[size.to_s]
+ return featured_image.blob unless variant_id
+
+ ActiveStorage::Blob.find_by(id: variant_id) || featured_image.blob
+ end
+
+ def image_url_for_size(size = :medium)
+ blob = image_variant(size)
+ return nil unless blob
+
+ Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
+ end
+
+ def has_processed_images?
+ image_variants.present?
+ end
+
+ def reprocess_image!
+ return unless featured_image.attached?
+
+ process_image_variants!
+ end
+
+ def clear_image_variants!
+ if image_variants.present?
+ image_variants.each do |_size, blob_id|
+ ActiveStorage::Blob.find_by(id: blob_id)&.purge_later
+ end
+ end
+
+ update_columns(image_variants: nil)
+ end
+
+ # Attach image from URL and process variants
+ def attach_image_from_url!(image_url)
+ return if image_url.blank?
+
+ require "open-uri"
+
+ image_io = URI.open(image_url,
+ "User-Agent" => "Ruby/#{RUBY_VERSION}",
+ read_timeout: 10,
+ open_timeout: 10
+ )
+
+ return if image_io.size > MAX_IMAGE_SIZE
+
+ temp_file = Tempfile.new([ "remote_image", File.extname(URI.parse(image_url).path) ])
+ temp_file.binmode
+ temp_file.write(image_io.read)
+ temp_file.rewind
+
+ featured_image.attach(io: temp_file, filename: File.basename(URI.parse(image_url).path))
+ process_image_variants!
+ rescue => e
+ Rails.logger.error "Failed to fetch/process image from URL #{image_url}: #{e.message}"
+ ensure
+ temp_file&.close
+ temp_file&.unlink
+ end
+
+ private
+
+ def generate_image_variant(source_path, config)
+ variant_file = Tempfile.new([ "variant", ".webp" ])
+
+ begin
+ cmd = [
+ "convert", source_path,
+ "-resize", "#{config[:width]}x#{config[:height]}>",
+ "-filter", "Lanczos",
+ "-quality", config[:quality].to_s,
+ "-define", "webp:lossless=false",
+ "-define", "webp:method=6",
+ "-define", "webp:alpha-quality=100",
+ "-define", "webp:image-hint=photo",
+ "-strip",
+ "webp:#{variant_file.path}"
+ ]
+
+ unless system(*cmd, err: File::NULL)
+ Rails.logger.error "Failed to generate variant: #{config.inspect}"
+ return nil
+ end
+
+ ActiveStorage::Blob.create_and_upload!(
+ io: File.open(variant_file.path),
+ filename: "variant_#{config[:width]}x#{config[:height]}.webp",
+ content_type: "image/webp"
+ )
+ ensure
+ variant_file.close
+ variant_file.unlink
+ end
+ end
+
+ def process_featured_image_if_needed
+ return unless featured_image.attached?
+
+ should_process = !has_processed_images? ||
+ (previous_changes.key?("updated_at") && featured_image.blob.created_at > 1.minute.ago)
+
+ return unless should_process
+
+ Rails.logger.info "Processing image for Post ##{id}"
+ result = process_image_variants!
+
+ if result[:success]
+ Rails.logger.info "Successfully processed image for Post ##{id}"
+ else
+ Rails.logger.error "Failed to process image for Post ##{id}: #{result[:error]}"
+ end
+ end
+
+ def featured_image_validation
+ return unless featured_image.attached?
+
+ if featured_image.blob.byte_size > MAX_IMAGE_SIZE
+ errors.add(:featured_image, "is too large (maximum is #{MAX_IMAGE_SIZE / 1.megabyte}MB)")
+ end
+
+ unless ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type)
+ errors.add(:featured_image, "must be a JPEG, PNG, WebP, or TIFF image")
+ end
+ end
+end
diff --git a/app/models/concerns/post/metadata_fetchable.rb b/app/models/concerns/post/metadata_fetchable.rb
new file mode 100644
index 0000000..dab5f10
--- /dev/null
+++ b/app/models/concerns/post/metadata_fetchable.rb
@@ -0,0 +1,204 @@
+module Post::MetadataFetchable
+ extend ActiveSupport::Concern
+
+ MAX_REDIRECTS = 3
+ DEFAULT_TIMEOUT = 10
+ DEFAULT_RETRIES = 1
+
+ # Fetch OpenGraph metadata from url
+ # Returns hash: { title:, description:, image_url:, parsed: }
+ def fetch_metadata!(options = {})
+ return {} if url.blank?
+
+ connection_timeout = options[:connection_timeout] || DEFAULT_TIMEOUT
+ read_timeout = options[:read_timeout] || DEFAULT_TIMEOUT
+ retries = options[:retries] || DEFAULT_RETRIES
+
+ html = fetch_html_with_retries(url, connection_timeout, read_timeout, retries)
+ return {} unless html
+
+ parsed = Nokogiri::HTML(html)
+
+ {
+ title: best_title(parsed),
+ description: best_description(parsed),
+ image_url: best_image(parsed),
+ parsed: parsed
+ }
+ rescue => e
+ Rails.logger.error "Failed to fetch metadata from #{url}: #{e.message}"
+ {}
+ end
+
+ # Fetch page text content (for AI summarization of link posts)
+ def fetch_external_content
+ return nil if url.blank?
+
+ html = fetch_html_with_retries(url, 5, 5, 1)
+ return nil unless html
+
+ parsed = Nokogiri::HTML(html)
+
+ content_parts = []
+
+ title_text = best_title(parsed)
+ content_parts << "Title: #{title_text}" if title_text.present?
+
+ desc_text = best_description(parsed)
+ content_parts << "Description: #{desc_text}" if desc_text.present?
+
+ main_content = extract_main_content(parsed)
+ content_parts << main_content if main_content.present?
+
+ if content_parts.length <= 2
+ raw_text = parsed.css("body").text.squish rescue nil
+ content_parts << raw_text if raw_text.present?
+ end
+
+ result = content_parts.join("\n\n")
+ result.presence
+ rescue => e
+ Rails.logger.error "Failed to fetch external content from #{url}: #{e.message}"
+ nil
+ end
+
+ private
+
+ def fetch_html_with_retries(target_url, connection_timeout, read_timeout, retries)
+ attempts = 0
+ begin
+ attempts += 1
+ fetch_html(target_url, connection_timeout, read_timeout)
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e
+ if attempts <= retries
+ retry
+ else
+ Rails.logger.error "Failed to fetch #{target_url} after #{retries} retries: #{e.message}"
+ nil
+ end
+ rescue => e
+ Rails.logger.error "Error fetching #{target_url}: #{e.message}"
+ nil
+ end
+ end
+
+ def fetch_html(target_url, connection_timeout, read_timeout, redirect_count = 0)
+ uri = URI(target_url)
+ return nil unless %w[http https].include?(uri.scheme&.downcase)
+
+ request = Net::HTTP::Get.new(uri)
+ request["User-Agent"] = "Ruby/#{RUBY_VERSION} (WhyRuby.info)"
+ request["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+
+ response = Net::HTTP.start(
+ uri.hostname, uri.port,
+ use_ssl: uri.scheme == "https",
+ open_timeout: connection_timeout,
+ read_timeout: read_timeout
+ ) { |http| http.request(request) }
+
+ case response
+ when Net::HTTPSuccess
+ response.body
+ when Net::HTTPRedirection
+ return nil if redirect_count >= MAX_REDIRECTS
+ location = response["location"]
+ return nil unless location
+ redirect_uri = URI.join(uri.to_s, location)
+ fetch_html(redirect_uri.to_s, connection_timeout, read_timeout, redirect_count + 1)
+ else
+ nil
+ end
+ end
+
+ def best_title(parsed)
+ extract_meta(parsed, property: "og:title") ||
+ extract_meta(parsed, name: "twitter:title") ||
+ parsed.at_css("title")&.text&.strip ||
+ parsed.at_css("h1")&.text&.strip
+ end
+
+ def best_description(parsed)
+ extract_meta(parsed, property: "og:description") ||
+ extract_meta(parsed, name: "twitter:description") ||
+ extract_meta(parsed, name: "description") ||
+ extract_first_paragraph(parsed)
+ end
+
+ def best_image(parsed)
+ og_image = extract_meta(parsed, property: "og:image")
+ return resolve_metadata_url(og_image) if og_image
+
+ twitter_image = extract_meta(parsed, name: "twitter:image")
+ return resolve_metadata_url(twitter_image) if twitter_image
+
+ largest = find_largest_image(parsed)
+ return resolve_metadata_url(largest) if largest
+
+ first_image = parsed.at_css("img")&.[]("src")
+ resolve_metadata_url(first_image) if first_image
+ end
+
+ def extract_meta(parsed, property: nil, name: nil)
+ if property
+ parsed.at_css("meta[property='#{property}']")&.[]("content")&.strip
+ elsif name
+ parsed.at_css("meta[name='#{name}']")&.[]("content")&.strip
+ end
+ end
+
+ def extract_first_paragraph(parsed)
+ parsed.css("p").each do |p|
+ text = p.text.strip
+ return text if text.length > 50
+ end
+ nil
+ end
+
+ def extract_main_content(parsed)
+ %w[main article [role="main"] .content #content .post-content .entry-content .article-body].each do |selector|
+ element = parsed.at_css(selector)
+ if element
+ text = element.text.squish
+ return text if text.length > 100
+ end
+ end
+
+ paragraphs = parsed.css("p").map(&:text).reject(&:blank?)
+ paragraphs.join(" ").presence
+ end
+
+ def find_largest_image(parsed)
+ largest = nil
+ max_size = 0
+
+ parsed.css("img").each do |img|
+ width = img["width"].to_i
+ height = img["height"].to_i
+ next if width.zero? || height.zero? || width < 200 || height < 200
+
+ aspect_ratio = width.to_f / height
+ next if aspect_ratio < 0.33 || aspect_ratio > 3.0
+
+ size = width * height
+ if size > max_size
+ max_size = size
+ largest = img["src"]
+ end
+ end
+
+ largest
+ end
+
+ def resolve_metadata_url(path)
+ return nil if path.blank?
+ return path if path.start_with?("http://", "https://")
+
+ begin
+ URI.join(url, path).to_s
+ rescue => e
+ Rails.logger.warn "Failed to resolve relative URL #{path}: #{e.message}"
+ path
+ end
+ end
+end
diff --git a/app/models/concerns/post/og_image_generatable.rb b/app/models/concerns/post/og_image_generatable.rb
new file mode 100644
index 0000000..135714e
--- /dev/null
+++ b/app/models/concerns/post/og_image_generatable.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Post::OgImageGeneratable
+ extend ActiveSupport::Concern
+
+ OG_TEMPLATE_PATH = Rails.root.join("app", "assets", "images", "success_story_template.webp")
+ OG_LOGO_MAX_WIDTH = 410
+ OG_LOGO_MAX_HEIGHT = 190
+ OG_LOGO_CENTER_X = 410
+ OG_LOGO_CENTER_Y = 145
+
+ # Generate OG image by overlaying SVG logo on success story template
+ def generate_og_image!(force: false)
+ return unless success_story? && logo_svg.present?
+ return unless system("which", "convert", out: File::NULL, err: File::NULL)
+
+ if force && featured_image.attached?
+ featured_image.purge
+ end
+
+ return if !force && featured_image.attached?
+
+ webp_data = composite_logo_on_template
+ return unless webp_data && !webp_data.empty?
+
+ featured_image.attach(
+ io: StringIO.new(webp_data),
+ filename: "#{slug}-social.webp",
+ content_type: "image/webp"
+ )
+
+ process_image_variants! if featured_image.attached?
+ end
+
+ private
+
+ def composite_logo_on_template
+ svg_file = Tempfile.new([ "logo", ".svg" ])
+ logo_file = Tempfile.new([ "logo_converted", ".webp" ])
+ output_file = Tempfile.new([ "success_story", ".webp" ])
+
+ begin
+ svg_file.write(logo_svg)
+ svg_file.rewind
+
+ return nil unless File.exist?(OG_TEMPLATE_PATH)
+
+ converted = try_rsvg_convert(svg_file.path, logo_file.path) ||
+ try_imagemagick_convert(svg_file.path, logo_file.path)
+
+ return nil unless converted
+
+ require "open3"
+ stdout, status = Open3.capture2("identify", "-format", "%wx%h", logo_file.path)
+ return nil unless status.success?
+
+ logo_width, logo_height = stdout.strip.split("x").map(&:to_i)
+ x_offset = OG_LOGO_CENTER_X - (logo_width / 2)
+ y_offset = OG_LOGO_CENTER_Y - (logo_height / 2)
+
+ composite_cmd = [
+ "convert", OG_TEMPLATE_PATH.to_s, logo_file.path,
+ "-geometry", "+#{x_offset}+#{y_offset}",
+ "-composite", "-quality", "95",
+ "-define", "webp:method=4",
+ "webp:#{output_file.path}"
+ ]
+
+ return nil unless system(*composite_cmd)
+
+ File.read(output_file.path)
+ rescue => e
+ Rails.logger.error "Failed to generate success story image: #{e.message}"
+ nil
+ ensure
+ [ svg_file, logo_file, output_file ].each { |f| f.close; f.unlink }
+ end
+ end
+
+ def try_rsvg_convert(svg_path, output_path)
+ return false unless system("which", "rsvg-convert", out: File::NULL, err: File::NULL)
+
+ temp_high_res = Tempfile.new([ "high_res", ".png" ])
+
+ begin
+ rsvg_cmd = [
+ "rsvg-convert", "--keep-aspect-ratio",
+ "--width", (OG_LOGO_MAX_WIDTH * 2).to_s,
+ "--height", (OG_LOGO_MAX_HEIGHT * 2).to_s,
+ "--background-color", "transparent",
+ svg_path, "--output", temp_high_res.path
+ ]
+
+ return false unless system(*rsvg_cmd, err: File::NULL)
+
+ resize_cmd = [
+ "convert", temp_high_res.path,
+ "-resize", "#{OG_LOGO_MAX_WIDTH}x#{OG_LOGO_MAX_HEIGHT}>",
+ "-filter", "Lanczos", "-quality", "95",
+ "-background", "none", "-gravity", "center",
+ "-define", "webp:method=6", "-define", "webp:alpha-quality=100",
+ "webp:#{output_path}"
+ ]
+
+ system(*resize_cmd)
+ ensure
+ temp_high_res.close
+ temp_high_res.unlink
+ end
+ end
+
+ def try_imagemagick_convert(svg_path, output_path)
+ cmd = [
+ "convert", "-background", "none", "-density", "300",
+ svg_path,
+ "-resize", "#{OG_LOGO_MAX_WIDTH}x#{OG_LOGO_MAX_HEIGHT}>",
+ "-filter", "Lanczos", "-quality", "95",
+ "-gravity", "center",
+ "-define", "webp:method=6", "-define", "webp:alpha-quality=100",
+ "webp:#{output_path}"
+ ]
+
+ system(*cmd)
+ end
+end
diff --git a/app/models/concerns/post/svg_sanitizable.rb b/app/models/concerns/post/svg_sanitizable.rb
new file mode 100644
index 0000000..23a96c3
--- /dev/null
+++ b/app/models/concerns/post/svg_sanitizable.rb
@@ -0,0 +1,149 @@
+module Post::SvgSanitizable
+ extend ActiveSupport::Concern
+
+ # Allowed SVG elements
+ ALLOWED_SVG_ELEMENTS = %w[
+ svg g path rect circle ellipse line polyline polygon text tspan textPath
+ defs pattern clipPath mask linearGradient radialGradient stop symbol use
+ image desc title metadata
+ ].freeze
+
+ # Allowed attributes (no event handlers)
+ ALLOWED_SVG_ATTRIBUTES = %w[
+ style class
+ viewbox preserveaspectratio
+ x y x1 y1 x2 y2 cx cy r rx ry
+ d points fill stroke stroke-width stroke-linecap stroke-linejoin
+ fill-opacity stroke-opacity opacity
+ transform translate rotate scale
+ font-family font-size font-weight text-anchor
+ href xlink:href
+ offset stop-color stop-opacity
+ gradientunits gradienttransform
+ patternunits patterntransform
+ clip-path mask
+ xmlns xmlns:xlink version
+ ].map(&:downcase).freeze
+
+ DANGEROUS_SVG_PATTERNS = [
+ /
+ <% end %>
+
+
+
+ <% if nullitics_enabled? %><% end %>
+ <%= render "shared/flash" %>
+
+
+ <%= yield %>
+
+
+