From 83197ced56e31ef65d1de67e21c484fed3089ad5 Mon Sep 17 00:00:00 2001
From: Andrew Brown
Date: Mon, 23 Jun 2025 21:51:26 -0700
Subject: [PATCH] add new tags page to profiles
---
CLAUDE.md | 131 +++++++++++
app/controllers/users_controller.rb | 132 ++++++++++-
.../display/attribute_value/_tags.html.erb | 4 +-
app/views/content/list/_cards.html.erb | 8 +-
app/views/content/list/_dense_cards.html.erb | 4 +-
.../content/list/_document_table.html.erb | 10 +-
app/views/content/list/_list.html.erb | 4 +-
app/views/users/profile/_tags.html.erb | 17 ++
app/views/users/show.html.erb | 78 +++----
app/views/users/tag.html.erb | 208 ++++++++++++++++++
config/routes.rb | 1 +
11 files changed, 551 insertions(+), 46 deletions(-)
create mode 100644 CLAUDE.md
create mode 100644 app/views/users/profile/_tags.html.erb
create mode 100644 app/views/users/tag.html.erb
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..81c2f117
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,131 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Common Commands
+
+### Setup and Installation
+```bash
+# Install dependencies
+bundle install
+
+# Install JavaScript dependencies
+yarn install
+
+# Setup database
+rake db:setup # Creates and seeds the database
+rake db:migrate # Apply all pending migrations
+```
+
+### Development
+```bash
+# Start the Rails server
+rails server
+# Or
+rails s
+
+# Start Sidekiq for background jobs
+bundle exec sidekiq
+
+# Start the development server with both web and worker processes
+RAILS_GROUPS=web,worker rails server
+```
+
+### Testing
+```bash
+# Run all tests
+rails test
+
+# Run a specific test file
+rails test path/to/test.rb
+
+# Run specific test
+rails test path/to/test.rb:LINE_NUMBER
+```
+
+### Database Operations
+```bash
+# Reset database (CAUTION: Destroys all data)
+rake db:reset
+
+# Migrate database
+rake db:migrate
+
+# Rollback last migration
+rake db:rollback
+```
+
+### Asset Management
+```bash
+# Compile assets
+rails assets:precompile
+```
+
+### Deployment
+```bash
+# Run in production mode
+RAILS_ENV=production rails server
+```
+
+## Architecture Overview
+
+Notebook.ai is a Rails application for writers and roleplayers to create and manage fictional universes and their components. The application follows standard Rails MVC architecture with some specific design patterns.
+
+### Key Concepts
+
+1. **Content Types**: The application revolves around different content types like Universe, Character, Location, etc. Each represents a different entity within a user's fictional world.
+
+2. **Content Pages**: All content types inherit from a shared ContentPage concern, which provides common functionality like attributes, privacy settings, and image uploads.
+
+3. **Universes**: The top-level organizational unit. Users create universes and add various content types within them.
+
+4. **Attributes System**: The application uses a flexible attributes system to store custom fields for each content type, allowing for extensibility without schema changes.
+
+5. **Privacy & Sharing**: Content can be private, public, or shared with specific collaborators.
+
+### Core Components
+
+#### Content Structure
+- **Universe**: The top-level container for all world-building elements
+- **Character**, **Location**, **Item**: Core content types that are available to all users
+- **Premium Content Types**: Many additional content types (Creature, Planet, Religion, etc.) available to premium users
+
+#### Key Modules and Concerns
+- `BelongsToUniverse`: Associates content with universes
+- `IsContentPage`: Provides shared content page functionality
+- `HasAttributes`: Handles dynamic attributes for content types
+- `HasPrivacy`: Manages content privacy settings
+- `HasImageUploads`: Handles image attachment functionality
+- `ContentPage`: Base behavior for all content pages
+
+#### Background Processing
+- Sidekiq is used for background job processing
+- Document analysis, exports, and other intensive tasks run asynchronously
+
+#### Data Flow
+1. Users create universes to contain their fictional worlds
+2. Within universes, users create various content types (characters, locations, etc.)
+3. Content types can be linked together with relationships (e.g., a character can be linked to locations)
+4. Users can collaborate on universes, allowing multiple people to contribute to the same world
+
+### Directory Structure
+
+Beyond the standard Rails structure, notable directories include:
+- `app/models/page_types/`: Contains all content type models
+- `app/models/page_groupers/`: Contains relationship models between content types
+- `config/attributes/`: Configuration for content type attributes
+- `app/authorizers/`: Authorization logic for content access
+
+### Key Files
+- `config/initializers/content_types.rb`: Defines all available content types
+- `app/models/content_page.rb`: Base functionality for all content pages
+- `app/controllers/content_controller.rb`: Base controller for all content types
+
+## Content Type System
+
+The application uses a content type system with these types of pages:
+- Universe (top-level container)
+- Character, Location, Item (core types)
+- Many premium content types like Creature, Planet, Religion, etc.
+
+Creating a new content type requires following the process in `docs/content_types.md`.
\ No newline at end of file
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 3d7ec93d..2d994640 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,5 +1,5 @@
class UsersController < ApplicationController
- before_action :set_user, only: [:show, :followers, :following]
+ before_action :set_user, only: [:show, :followers, :following, :tag]
def index
redirect_to new_session_path(User)
@@ -15,6 +15,9 @@ class UsersController < ApplicationController
@content = @user.public_content.select { |type, list| list.any? }
@tabs = @content.keys
+
+ # Get popular tags for this user's public content
+ @popular_tags = get_popular_public_tags_for_user(@user)
@favorite_content = @user.favorite_page_type? ? @user.send(@user.favorite_page_type.downcase.pluralize).is_public : []
@stream = @user.recent_content_list(limit: 20)
@@ -91,6 +94,88 @@ class UsersController < ApplicationController
def following
end
+
+ def tag
+ @tag_slug = params[:tag_slug]
+ @tag = PageTag.find_by(user_id: @user.id, slug: @tag_slug)
+
+ return redirect_to(profile_by_username_path(username: @user.username), notice: 'That tag does not exist.') if @tag.nil?
+
+ # Find all public content with this tag
+ @tagged_content = []
+
+ # Go through each content type and find items with this tag
+ Rails.application.config.content_types[:all].each do |content_type|
+ content_pages = content_type.joins(:page_tags)
+ .where(privacy: 'public')
+ .where(user_id: @user.id)
+ .where(page_tags: { slug: @tag_slug })
+ .order(:name)
+
+ @tagged_content << {
+ type: content_type.name,
+ icon: content_type.icon,
+ color: content_type.color,
+ content: content_pages
+ } if content_pages.any?
+ end
+
+ # Add documents and timelines if they have the tag
+ # Handle documents separately since they don't use the common content type structure
+ documents = Document.joins(:page_tags)
+ .where(privacy: 'public')
+ .where(user_id: @user.id)
+ .where(page_tags: { slug: @tag_slug })
+ .order(:title) # Documents use 'title' instead of 'name'
+
+ @tagged_content << {
+ type: 'Document',
+ icon: 'description',
+ color: 'blue',
+ content: documents
+ } if documents.any?
+
+ # Handle timelines separately since they don't use the common content type structure
+ timelines = Timeline.joins(:page_tags)
+ .where(privacy: 'public')
+ .where(user_id: @user.id)
+ .where(page_tags: { slug: @tag_slug })
+ .order(:name)
+
+ @tagged_content << {
+ type: 'Timeline',
+ icon: 'timeline',
+ color: 'blue',
+ content: timelines
+ } if timelines.any?
+
+ # Get images for content cards
+ @random_image_including_private_pool_cache = ImageUpload.where(
+ user_id: @user.id,
+ ).group_by { |image| [image.content_type, image.content_id] }
+
+ # Collect all content IDs and types for fetching basil commissions
+ basil_entity_types = []
+ basil_entity_ids = []
+
+ @tagged_content.each do |content_group|
+ content_group[:content].each do |content|
+ basil_entity_types << content.class.name
+ basil_entity_ids << content.id
+ end
+ end
+
+ # Initialize @saved_basil_commissions if there are any content items
+ if basil_entity_types.any?
+ @saved_basil_commissions = BasilCommission.where(
+ entity_type: basil_entity_types,
+ entity_id: basil_entity_ids
+ ).where.not(saved_at: nil)
+ .group_by { |commission| [commission.entity_type, commission.entity_id] }
+ end
+
+ @sidenav_expansion = 'community'
+ end
private
@@ -107,4 +192,49 @@ class UsersController < ApplicationController
def user_params
params.permit(:id, :username)
end
+
+ # Get most popular tags for a user's public content
+ def get_popular_public_tags_for_user(user, limit: 10)
+ # Find page tags attached to public content
+ public_page_tags = []
+
+ # For each content type, find public pages with tags
+ Rails.application.config.content_types[:all].each do |content_type|
+ # Skip if a user has no pages of this type
+ next unless user.respond_to?(content_type.name.downcase.pluralize)
+
+ # Get all public pages of this content type
+ public_pages = user.send(content_type.name.downcase.pluralize).is_public
+
+ # Skip if there are no public pages
+ next if public_pages.empty?
+
+ # Find all page tags for these pages
+ tag_ids = PageTag.where(page_type: content_type.name, page_id: public_pages.pluck(:id)).pluck(:id)
+ public_page_tags.concat(tag_ids) if tag_ids.any?
+ end
+
+ # Also include Document and Timeline tags if they're public
+ public_documents = user.documents.where(privacy: 'public')
+ if public_documents.any?
+ document_tag_ids = PageTag.where(page_type: 'Document', page_id: public_documents.pluck(:id)).pluck(:id)
+ public_page_tags.concat(document_tag_ids) if document_tag_ids.any?
+ end
+
+ public_timelines = user.timelines.where(privacy: 'public')
+ if public_timelines.any?
+ timeline_tag_ids = PageTag.where(page_type: 'Timeline', page_id: public_timelines.pluck(:id)).pluck(:id)
+ public_page_tags.concat(timeline_tag_ids) if timeline_tag_ids.any?
+ end
+
+ # If we have tags, find the most popular ones
+ return [] if public_page_tags.empty?
+
+ # Get the actual tags
+ PageTag.where(id: public_page_tags)
+ .select('tag, slug, COUNT(*) as usage_count')
+ .group(:tag, :slug)
+ .order('usage_count DESC')
+ .limit(limit)
+ end
end
diff --git a/app/views/content/display/attribute_value/_tags.html.erb b/app/views/content/display/attribute_value/_tags.html.erb
index b0b68770..09fd1ce1 100644
--- a/app/views/content/display/attribute_value/_tags.html.erb
+++ b/app/views/content/display/attribute_value/_tags.html.erb
@@ -15,7 +15,9 @@
<% end %>
<% else %>
-
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: PageTagService.slug_for(tag)) do %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/content/list/_cards.html.erb b/app/views/content/list/_cards.html.erb
index 8e70fb34..7f61aa0b 100644
--- a/app/views/content/list/_cards.html.erb
+++ b/app/views/content/list/_cards.html.erb
@@ -33,7 +33,9 @@
<% end %>
<% else %>
-
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
<% end %>
<% end %>
@@ -80,7 +82,9 @@
<% end %>
<% else %>
-
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/content/list/_dense_cards.html.erb b/app/views/content/list/_dense_cards.html.erb
index b67a5f71..42d8f1ee 100644
--- a/app/views/content/list/_dense_cards.html.erb
+++ b/app/views/content/list/_dense_cards.html.erb
@@ -31,7 +31,9 @@
<% end %>
<% else %>
-
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/content/list/_document_table.html.erb b/app/views/content/list/_document_table.html.erb
index 71411a28..9b6faa2d 100644
--- a/app/views/content/list/_document_table.html.erb
+++ b/app/views/content/list/_document_table.html.erb
@@ -61,7 +61,15 @@
<% if page_tags.any? %>
<% page_tags.each do |tag| %>
-
+ <% if user_signed_in? && document.user == current_user %>
+ <%= link_to params.permit(:tag).merge({ tag: tag.slug }) do %>
+
+ <% end %>
+ <% else %>
+ <%= link_to user_tag_path(username: document.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/content/list/_list.html.erb b/app/views/content/list/_list.html.erb
index 27447b8f..0b16808f 100644
--- a/app/views/content/list/_list.html.erb
+++ b/app/views/content/list/_list.html.erb
@@ -61,7 +61,9 @@
<% end %>
<% else %>
-
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
<% end %>
<% end %>
<% end %>
diff --git a/app/views/users/profile/_tags.html.erb b/app/views/users/profile/_tags.html.erb
new file mode 100644
index 00000000..c73d6b95
--- /dev/null
+++ b/app/views/users/profile/_tags.html.erb
@@ -0,0 +1,17 @@
+<% if @popular_tags.any? %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 72c131cd..8d0881a7 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -39,9 +39,9 @@
- - About Me
+ - Notebook
+ - About Me
- Recent Activity
- - Universes
<% if show_collections_tab %>
- Collections
@@ -53,7 +53,43 @@
-
+
+ <% if user_signed_in? && @user.blocked_by?(current_user) %>
+
+ You've blocked this user.
+
+ <% else %>
+
+ <% @user.universes.is_public.each do |universe| %>
+
+ <%= link_to universe do %>
+
+
+ <%= image_tag universe.first_public_image %>
+ <%= universe.name %>
+
+
+
<%= truncate(universe.description, length: 140) %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ You can also browse this user's public pages directly.
+ <% if @content.empty? %>
+ However, they haven't made anything public yet!
+ <% end %>
+
+
+ <%= render partial: 'users/profile/public_pages' %>
+ <%= render partial: 'users/profile/tags' %>
+
+
+ <% end %>
+
@@ -89,42 +125,6 @@
<% end %>
<% end %>
-
- <% if user_signed_in? && @user.blocked_by?(current_user) %>
-
- You've blocked this user.
-
- <% else %>
-
- <% @user.universes.is_public.each do |universe| %>
-
- <%= link_to universe do %>
-
-
- <%= image_tag universe.first_public_image %>
- <%= universe.name %>
-
-
-
<%= truncate(universe.description, length: 140) %>
-
-
- <% end %>
-
- <% end %>
-
-
-
- You can also browse this user's public pages directly.
- <% if @content.empty? %>
- However, they haven't made anything public yet!
- <% end %>
-
-
- <%= render partial: 'users/profile/public_pages' %>
-
-
- <% end %>
-
<% if user_signed_in? && @user.blocked_by?(current_user) %>
diff --git a/app/views/users/tag.html.erb b/app/views/users/tag.html.erb
new file mode 100644
index 00000000..b75d32f8
--- /dev/null
+++ b/app/views/users/tag.html.erb
@@ -0,0 +1,208 @@
+
+
+ <%
+ # Generate a consistent pattern URL for the background based on tag name
+ pattern_seed = @tag.tag.bytes.sum % 5 + 1
+ pattern_url = asset_path("card-headers/patterns/pattern#{pattern_seed}.png")
+
+ # Calculate total items for the stats display
+ total_items = @tagged_content.sum { |group| group[:content].count }
+ content_types_count = @tagged_content.size
+ %>
+
+
+
+
+
+
+
+
+
+
+
+ <%= PageTag.icon %>
+ <%= @tag.tag %>
+
+
+
+
+
+
+ <% if @user.image_url %>
+ <%= image_tag @user.image_url.html_safe, style: 'width: 56px; height: 56px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.2);' %>
+ <% else %>
+
+ person
+
+ <% end %>
+
+
+
+ <%= link_to profile_by_username_path(username: @user.username), class: "white-text" do %>
+
+ <%= @user.display_name %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= PageTag.icon %>
+ <%= pluralize(total_items, 'item') %>
+
+
+
+ category
+ <%= pluralize(content_types_count, 'category') %>
+
+
+ <%= link_to profile_by_username_path(username: @user.username), class: "chip grey lighten-4" do %>
+
person
+ View profile
+ <% end %>
+
+
+
+
+
+
+
+ <% if @tagged_content.empty? %>
+
+
No public content with this tag
+
+ Either <%= @user.display_name %> has not tagged any public content with "<%= @tag.tag %>" or this content has been deleted.
+
+
+ <% else %>
+ <% @tagged_content.each do |content_group| %>
+
+
+ <%= content_group[:icon] %>
+ <%= content_group[:type].pluralize %> (<%= content_group[:content].count %>)
+
+
+ <%
+ content_type = content_group[:type]
+ if content_type == "Document"
+ content_type_path = profile_by_username_path(username: @user.username)
+ elsif content_type == "Timeline"
+ content_type_path = nil # No specific timeline route in user profile
+ else
+ content_type_path = send("#{content_type.downcase.pluralize}_user_path", id: @user.id) rescue nil
+ end
+ %>
+ <% if content_type_path %>
+ <%= link_to content_type_path, class: "grey-text" do %>
+ visibility
+ View all <%= content_group[:type].downcase.pluralize %>
+ <% end %>
+ <% end %>
+
+
+
+
+ <% content_group[:content].each do |content| %>
+
+
+
+ <%
+ # Find image for this content following the same pattern as in _cards.html.erb
+ content_image = @random_image_including_private_pool_cache.fetch([content.class.name, content.id], [])
+ .sample
+ .try(:src, :medium)
+
+ if @saved_basil_commissions
+ content_image ||= @saved_basil_commissions.fetch([content.class.name, content.id], [])
+ .sample
+ .try(:image)
+ .try(:url)
+ end
+
+ content_image ||= asset_path("card-headers/#{content.class.name.downcase.pluralize}.jpg")
+ %>
+
+
+
+
+ <% content_name = content.respond_to?(:name) ? content.name : content.title %>
+ <%= ContentFormatterService.show(text: content_name.presence || 'Untitled', viewing_user: current_user) %>
+
+
+ <% if content.respond_to?(:page_tags) %>
+
+ <% content.page_tags.each do |tag| %>
+ <% if tag.tag == @tag.tag %>
+
+ <% else %>
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+
+ <% if user_signed_in? %>
+
+ <% if content.is_a?(Document) %>
+ <%= link_to 'View', content, class: 'blue-text text-lighten-1', target: '_blank' %>
+ <% else %>
+ <%= link_to 'View', content, class: 'blue-text text-lighten-1' %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= content_name.presence || 'Untitled' %>
+ close
+
+ <% content_description = content.try(:description) || content.try(:synopsis) %>
+ <% if content_description.present? %>
+
+ <%= sanitize ContentFormatterService.show(text: truncate(content_description, length: 420, escape: false), viewing_user: current_user) %>
+
+ <% end %>
+
+ <% if content.respond_to?(:page_tags) %>
+
+ <% content.page_tags.each do |tag| %>
+ <% if tag.tag == @tag.tag %>
+
+ <% else %>
+ <%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= content.created_at == content.updated_at ? 'created' : 'last updated' %>
+ <%= time_ago_in_words content.updated_at %>
+ ago
+
+
+
+
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
+
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index dacd82d9..1411a9b5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -102,6 +102,7 @@ Rails.application.routes.draw do
get '/@:username', to: 'users#show', as: :profile_by_username
get '/@:username/followers', to: 'users#followers'
get '/@:username/following', to: 'users#following'
+ get '/@:username/tag/:tag_slug', to: 'users#tag', as: :user_tag
resources :documents do
# Document Analysis routes