add new tags page to profiles

This commit is contained in:
Andrew Brown 2025-06-23 21:51:26 -07:00
parent 0cc79606ee
commit 83197ced56
11 changed files with 551 additions and 46 deletions

131
CLAUDE.md Normal file
View File

@ -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`.

View File

@ -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

View File

@ -15,7 +15,9 @@
<span class="new badge <%= raw_model.class.color %> left" data-badge-caption="<%= tag %>"></span>
<% end %>
<% else %>
<span class="new badge <%= raw_model.class.color %> left" data-badge-caption="<%= tag %>"></span>
<%= link_to user_tag_path(username: content.user.username, tag_slug: PageTagService.slug_for(tag)) do %>
<span class="new badge <%= raw_model.class.color %> left" data-badge-caption="<%= tag %>"></span>
<% end %>
<% end %>
<% end %>
</div>

View File

@ -33,7 +33,9 @@
<span class="new badge <%= params[:tag] == tag.slug ? content_type.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% else %>
<span class="new badge <%= params[:tag] == tag.slug ? content_type.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
<span class="new badge <%= params[:tag] == tag.slug ? content_type.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
</p>
@ -80,7 +82,9 @@
<span class="new badge <%= params[:tag] == tag.slug ? content_type.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% else %>
<span class="new badge <%= params[:tag] == tag.slug ? content_type.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
<span class="new badge <%= params[:tag] == tag.slug ? content_type.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
</p>

View File

@ -31,7 +31,9 @@
<span class="new badge <%= params[:tag] == tag.slug ? 'orange' : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% else %>
<span class="new badge <%= params[:tag] == tag.slug ? 'orange' : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
<span class="new badge <%= params[:tag] == tag.slug ? 'orange' : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
</p>

View File

@ -61,7 +61,15 @@
<% if page_tags.any? %>
<p class="tags-container">
<% page_tags.each do |tag| %>
<span class="new badge <%= params[:tag] == tag.slug ? 'orange' : Document.color %> left" data-badge-caption="<%= tag.tag %>"></span>
<% if user_signed_in? && document.user == current_user %>
<%= link_to params.permit(:tag).merge({ tag: tag.slug }) do %>
<span class="new badge <%= params[:tag] == tag.slug ? 'orange' : Document.color %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% else %>
<%= link_to user_tag_path(username: document.user.username, tag_slug: tag.slug) do %>
<span class="new badge <%= params[:tag] == tag.slug ? 'orange' : Document.color %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
</p>
<% end %>

View File

@ -61,7 +61,9 @@
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% else %>
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@ -0,0 +1,17 @@
<% if @popular_tags.any? %>
<div class="collection with-header hoverable">
<div class="collection-header blue lighten-1 white-text">
<div style="padding: 5px 10px">
Tags
</div>
</div>
<div class="collection-item" style="padding: 14px">
<% @popular_tags.each do |tag| %>
<%= link_to user_tag_path(username: @user.username, tag_slug: tag.slug) do %>
<span class="new badge blue lighten-1 left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<div style="clear: both"></div>
</div>
</div>
<% end %>

View File

@ -39,9 +39,9 @@
</div>
<div class="card-tabs">
<ul class="tabs tabs-fixed-width">
<li class="tab col s3"><a class="active blue-text" href="#tab-about-me">About Me</a></li>
<li class="tab col s3"><a class="active blue-text" href="#tab-universes">Notebook</a></li>
<li class="tab col s3"><a class="blue-text" href="#tab-about-me">About Me</a></li>
<li class="tab col s3"><a class="blue-text" href="#tab-recent-activity">Recent Activity</a></li>
<li class="tab col s3"><a class="blue-text" href="#tab-universes">Universes</a></li>
<!--<li class="tab col s3"><a class="blue-text" href="#tab-documents">Documents</a></li>-->
<% if show_collections_tab %>
<li class="tab col s3"><a class="blue-text" href="#tab-collections">Collections</a></li>
@ -53,7 +53,43 @@
<div class="row user-profile-ui">
<div class="col s12">
<div id="tab-universes">
<% if user_signed_in? && @user.blocked_by?(current_user) %>
<p class="card-panel red center lighten-5 black-text">
You've blocked this user.
</p>
<% else %>
<div class="row" style="margin-top: 100px">
<% @user.universes.is_public.each do |universe| %>
<div class="col s12 m12 l6">
<%= link_to universe do %>
<div class="hoverable card">
<div class="card-image">
<%= image_tag universe.first_public_image %>
<span class="card-title"><%= universe.name %></span>
</div>
<div class="card-content <%= Universe.color %> white-text fixed-card-content">
<p><%= truncate(universe.description, length: 140) %></p>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<div class="row">
<div class="col s12 m4 l4">
You can also browse this user's public pages directly.
<% if @content.empty? %>
However, they haven't made anything public yet!
<% end %>
</div>
<div class="col s12 m8 l8">
<%= render partial: 'users/profile/public_pages' %>
<%= render partial: 'users/profile/tags' %>
</div>
</div>
<% end %>
</div>
<div id="tab-about-me">
<div class="row">
<div class="col s12">
@ -89,42 +125,6 @@
<% end %>
<% end %>
</div>
<div id="tab-universes">
<% if user_signed_in? && @user.blocked_by?(current_user) %>
<p class="card-panel red center lighten-5 black-text">
You've blocked this user.
</p>
<% else %>
<div class="row">
<% @user.universes.is_public.each do |universe| %>
<div class="col s12 m12 l6">
<%= link_to universe do %>
<div class="hoverable card">
<div class="card-image">
<%= image_tag universe.first_public_image %>
<span class="card-title"><%= universe.name %></span>
</div>
<div class="card-content <%= Universe.color %> white-text fixed-card-content">
<p><%= truncate(universe.description, length: 140) %></p>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<div class="row">
<div class="col s12 m4 l4">
You can also browse this user's public pages directly.
<% if @content.empty? %>
However, they haven't made anything public yet!
<% end %>
</div>
<div class="col s12 m8 l8">
<%= render partial: 'users/profile/public_pages' %>
</div>
</div>
<% end %>
</div>
<div id="tab-collections">
<% if user_signed_in? && @user.blocked_by?(current_user) %>
<p class="card-panel red center lighten-5 black-text">

View File

@ -0,0 +1,208 @@
<div class="row" style="margin-bottom: 0;">
<div class="col s12">
<%
# 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
%>
<div class="card z-depth-1" style="border-radius: 4px; overflow: hidden;">
<!-- Header with hero image background -->
<div class="<%= @accent_color %>" style="position: relative; background: url('<%= pattern_url %>') center center; background-size: cover; height: 180px;">
<!-- Dark overlay for better text contrast -->
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.3);"></div>
<!-- Tag floating badge -->
<div style="position: absolute; bottom: -24px; left: 24px; z-index: 2;">
<div style="display: inline-block; background-color: white; padding: 14px 22px; border-radius: 4px; box-shadow: 0 2px 10px 0 rgba(0,0,0,0.2);">
<div style="display: flex; align-items: center;">
<i class="material-icons <%= @accent_color %>-text" style="font-size: 28px; margin-right: 12px;"><%= PageTag.icon %></i>
<span style="font-size: 28px; font-weight: 500;" class="<%= @accent_color %>-text"><%= @tag.tag %></span>
</div>
</div>
</div>
<!-- User info - prominent but elegant -->
<div style="position: absolute; top: 20px; left: 24px; z-index: 1; display: flex; align-items: center;">
<% 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 %>
<div style="width: 56px; height: 56px; background-color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
<i class="material-icons" style="font-size: 32px; color: #757575;">person</i>
</div>
<% end %>
<div style="margin-left: 15px;">
<div>
<%= link_to profile_by_username_path(username: @user.username), class: "white-text" do %>
<span style="font-size: 20px; font-weight: 500; text-shadow: 0 1px 3px rgba(0,0,0,0.3); line-height: 1.2; display: block;">
<%= @user.display_name %>
</span>
<% end %>
</div>
</div>
</div>
</div>
<!-- Stats bar - clean and elegant -->
<div class="<%= @accent_color %> lighten-5" style="padding: 30px 24px 16px 24px;">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center;">
<!-- Left section (for title) -->
<div style="margin-bottom: 10px;">
&nbsp;
</div>
<!-- Right section (for stats) -->
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<div class="chip z-depth-0 <%= @accent_color %> lighten-3" style="border: none; margin: 0;">
<i class="material-icons left"><%= PageTag.icon %></i>
<%= pluralize(total_items, 'item') %>
</div>
<div class="chip z-depth-0 <%= @accent_color %> lighten-3" style="border: none; margin: 0;">
<i class="material-icons left">category</i>
<%= pluralize(content_types_count, 'category') %>
</div>
<%= link_to profile_by_username_path(username: @user.username), class: "chip grey lighten-4" do %>
<i class="material-icons left">person</i>
View profile
<% end %>
</div>
</div>
</div>
</div>
</div>
<div class="col s12">
<% if @tagged_content.empty? %>
<div class="center-align" style="margin-top: 80px">
<h5 class="grey-text">No public content with this tag</h5>
<p class="grey-text">
Either <%= @user.display_name %> has not tagged any public content with "<%= @tag.tag %>" or this content has been deleted.
</p>
</div>
<% else %>
<% @tagged_content.each do |content_group| %>
<div class="section">
<h5 class="<%= content_group[:color] %>-text">
<i class="material-icons left"><%= content_group[:icon] %></i>
<%= content_group[:type].pluralize %> (<%= content_group[:content].count %>)
<span class="grey-text right" style="font-size: 14px; font-weight: normal; margin-top: 5px;">
<%
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 %>
<i class="material-icons tiny" style="position: relative; top: 2px;">visibility</i>
View all <%= content_group[:type].downcase.pluralize %>
<% end %>
<% end %>
</span>
</h5>
<div class="row js-content-cards-list">
<% content_group[:content].each do |content| %>
<div class="col s12 m6 l4 js-content-card-container">
<div class="hoverable card sticky-action" style="margin-bottom: 2px">
<div class="card-image waves-effect waves-block waves-light">
<%
# 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")
%>
<div class="activator" style="height: 265px; background: url('<%= content_image %>'); background-size: cover;"></div>
<span class="card-title js-content-name activator">
<div class="bordered-text">
<% content_name = content.respond_to?(:name) ? content.name : content.title %>
<%= ContentFormatterService.show(text: content_name.presence || 'Untitled', viewing_user: current_user) %>
</div>
<% if content.respond_to?(:page_tags) %>
<p class="tags-container">
<% content.page_tags.each do |tag| %>
<% if tag.tag == @tag.tag %>
<span class="new badge <%= content_group[:color] %> left" data-badge-caption="<%= tag.tag %>"></span>
<% else %>
<%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
<span class="new badge grey left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
</p>
<% end %>
</span>
</div>
<% if user_signed_in? %>
<div class="card-action">
<% 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 %>
</div>
<% end %>
<div class="card-reveal">
<span class="card-title">
<%= content_name.presence || 'Untitled' %>
<i class="material-icons right">close</i>
</span>
<% content_description = content.try(:description) || content.try(:synopsis) %>
<% if content_description.present? %>
<p>
<%= sanitize ContentFormatterService.show(text: truncate(content_description, length: 420, escape: false), viewing_user: current_user) %>
</p>
<% end %>
<% if content.respond_to?(:page_tags) %>
<p class="tags-container">
<% content.page_tags.each do |tag| %>
<% if tag.tag == @tag.tag %>
<span class="new badge <%= content_group[:color] %> left" data-badge-caption="<%= tag.tag %>"></span>
<% else %>
<%= link_to user_tag_path(username: content.user.username, tag_slug: tag.slug) do %>
<span class="new badge grey left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>
</p>
<% end %>
<div class="clearfix"></div>
<p class="grey-text clearfix">
<%= content.created_at == content.updated_at ? 'created' : 'last updated' %>
<%= time_ago_in_words content.updated_at %>
ago
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>

View File

@ -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