diff --git a/.gitignore b/.gitignore
index 9ed66fe2d..f329d9bde 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@
# Ignore assets
/public/assets
+/public/system/*
storage/*
# Super-secret stuff
@@ -41,3 +42,5 @@ node_modules
yarn-debug.log*
.yarn-integrity
+# Heroku stuff
+.env
diff --git a/CLAUDE.md b/CLAUDE.md
index 81c2f1175..37c21e9c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,4 +128,17 @@ The application uses a content type system with these types of pages:
- 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
+Creating a new content type requires following the process in `docs/content_types.md`.
+
+## Tailwind CSS Conventions
+
+This project uses Tailwind CSS for styling. **Do not use the following Tailwind features** as they are not supported in our configuration:
+
+1. **Arbitrary values** - Do not use bracket syntax like `text-[10px]`, `w-[200px]`, `mt-[17px]`, etc. Use only standard Tailwind classes or add custom values to `tailwind.config.js` if needed.
+
+2. **Opacity shorthand** - Do not use slash syntax like `text-white/80`, `bg-black/40`, `border-gray-500/50`, etc. Instead, use separate opacity utilities:
+ - For backgrounds: `bg-black bg-opacity-40`
+ - For text: `text-white` with a wrapper using `opacity-80` if needed
+ - For borders: `border-gray-500 border-opacity-50`
+
+3. **Custom sizes** - If you need a size that doesn't exist (e.g., smaller than `text-xs`), add it to the `extend` section in `tailwind.config.js`. For example, `text-xxs` is available for 10px text.
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 80f0a7c5c..76f9f76bc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# The image to build from.
-FROM ruby:3.2.1-slim
+FROM ruby:3.2.3-slim
# Properties/labels for the image.
LABEL maintainer="Notebook.ai Contributors"
diff --git a/Dockerfile.debug b/Dockerfile.debug
new file mode 100644
index 000000000..c2ed87067
--- /dev/null
+++ b/Dockerfile.debug
@@ -0,0 +1,19 @@
+FROM ruby:3.2.3-slim
+RUN groupadd --system --gid 1000 notebookai && useradd --system --home-dir /home/notebookai --gid notebookai --uid 1000 --shell /bin/bash notebookai
+RUN apt-get update -qq && apt-get install -y build-essential libpq-dev imagemagick libmagickwand-dev curl git libjemalloc2 && rm --recursive --force /var/lib/apt/lists/*
+RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" && \
+ case "${dpkgArch##*-}" in \
+ amd64) ARCH='x64';; \
+ arm64) ARCH='arm64';; \
+ *) echo "unsupported architecture"; exit 1 ;; \
+ esac && \
+ curl -fsSLO "https://nodejs.org/dist/v20.12.2/node-v20.12.2-linux-$ARCH.tar.gz" && \
+ tar -xzf "node-v20.12.2-linux-$ARCH.tar.gz" -C /usr/local --strip-components=1 && \
+ rm "node-v20.12.2-linux-$ARCH.tar.gz"
+RUN npm install -g yarn@1.22.22
+WORKDIR /home/notebookai
+COPY Gemfile Gemfile.lock package.json yarn.lock ./
+RUN bundle install && yarn install
+COPY . .
+RUN chown -R notebookai:notebookai /home/notebookai
+USER notebookai
diff --git a/Gemfile b/Gemfile
index 0a4f74df9..f90fdd686 100644
--- a/Gemfile
+++ b/Gemfile
@@ -24,6 +24,7 @@ gem 'filesize'
gem 'paperclip' # TODO: we want to migrate off this game to ActiveStorage
gem 'rmagick'
gem 'image_processing'
+gem 'mini_magick'
gem 'active_storage_validations'
# Authentication
@@ -55,7 +56,7 @@ gem 'react-rails'
# Form enhancements
gem 'redcarpet' #markdown formatting
gem 'acts_as_list' #sortables
-gem 'tribute' # @mentions
+# gem 'tribute' # @mentions - Replaced with Alpine.js implementation
# SEO
gem 'meta-tags'
@@ -106,6 +107,9 @@ gem 'redis', '~> 5.1.0'
# Exports
gem 'csv'
+# Diff generation for document revisions
+gem 'diffy'
+
# Admin
gem 'rails_admin'
@@ -132,12 +136,9 @@ end
group :test, :production do
gem 'pg', '~> 1.5'
-
- gem "mini_racer", "~> 0.6.3" # TODO: audit whether we can remove this
end
group :test do
- gem 'codeclimate-test-reporter', require: false # TODO: remove this
gem 'database_cleaner'
gem 'selenium-webdriver'
gem 'rspec-rails', '~> 5.0'
@@ -148,7 +149,7 @@ end
group :development do
gem 'web-console'
- gem 'listen'
+ # gem 'listen'
gem 'bullet'
gem 'rack-mini-profiler'
gem 'memory_profiler'
@@ -167,3 +168,4 @@ group :worker do
gem 'ibm_watson'
gem 'textstat'
end
+
diff --git a/Gemfile.lock b/Gemfile.lock
index 70146a66e..21b609256 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1797,8 +1797,6 @@ GEM
logger (~> 1.5)
climate_control (0.2.0)
cocoon (1.2.15)
- codeclimate-test-reporter (1.0.9)
- simplecov (<= 0.13)
coderay (1.1.3)
coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
@@ -1835,6 +1833,7 @@ GEM
responders
warden (~> 1.2.3)
diff-lcs (1.6.2)
+ diffy (3.4.4)
discordrb (3.5.0)
discordrb-webhooks (~> 3.5.0)
ffi (>= 1.9.24)
@@ -1843,7 +1842,6 @@ GEM
websocket-client-simple (>= 0.3.0)
discordrb-webhooks (3.5.0)
rest-client (>= 2.0.0)
- docile (1.1.5)
domain_name (0.6.20240107)
dotenv (3.1.8)
dotenv-rails (3.1.8)
@@ -1936,7 +1934,6 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
jmespath (1.6.2)
- json (2.11.3)
jwt (2.2.3)
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -1966,12 +1963,6 @@ GEM
letter_opener (~> 1.9)
railties (>= 6.1)
rexml
- libv8-node (16.19.0.1-aarch64-linux)
- libv8-node (16.19.0.1-arm64-darwin)
- libv8-node (16.19.0.1-x86_64-linux)
- listen (3.9.0)
- rb-fsevent (~> 0.10, >= 0.10.3)
- rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
loofah (2.24.0)
crass (~> 1.0.2)
@@ -1999,8 +1990,6 @@ GEM
benchmark
logger
mini_mime (1.1.5)
- mini_racer (0.6.4)
- libv8-node (~> 16.19.0.0)
minitest (5.25.5)
minitest-reporters (1.7.1)
ansi
@@ -2117,9 +2106,6 @@ GEM
rake (>= 12.2)
thor (~> 1.0)
rake (13.2.1)
- rb-fsevent (0.11.2)
- rb-inotify (0.11.1)
- ffi (~> 1.0)
react-rails (3.2.1)
babel-transpiler (>= 0.7.0)
connection_pool
@@ -2201,11 +2187,6 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
- simplecov (0.13.0)
- docile (~> 1.1.0)
- json (>= 1.8, < 3)
- simplecov-html (~> 0.10.0)
- simplecov-html (0.10.2)
sin_lru_redux (2.5.2)
slack-notifier (2.4.0)
spring (4.3.0)
@@ -2241,7 +2222,6 @@ GEM
tilt (2.6.0)
timeago_js (3.0.2.2)
timeout (0.4.3)
- tribute (3.6.0.0)
turbo-rails (2.0.12)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
@@ -2301,13 +2281,13 @@ DEPENDENCIES
byebug
chartkick
cocoon
- codeclimate-test-reporter
coffee-rails
csv
d3-rails (~> 5.9.2)
database_cleaner
dateslices
devise
+ diffy
discordrb
dotenv-rails
engtagger!
@@ -2321,11 +2301,10 @@ DEPENDENCIES
image_processing
language_filter
letter_opener_web
- listen
material_icons
memory_profiler
meta-tags
- mini_racer (~> 0.6.3)
+ mini_magick
minitest-reporters (~> 1.1)
onebox!
paperclip
@@ -2363,7 +2342,6 @@ DEPENDENCIES
terser
textstat
thredded!
- tribute
uglifier (>= 1.3.0)
web-console
webmock (~> 3.0)
diff --git a/README.rdoc b/README.rdoc
index b3e615f5b..ae0dede94 100644
--- a/README.rdoc
+++ b/README.rdoc
@@ -1,6 +1,4 @@
= Notebook.ai
-{}[https://codeclimate.com/github/indentlabs/notebook]
-{
}[https://codeclimate.com/github/indentlabs/notebook/coverage]
{
}[http://inch-ci.org/github/indentlabs/notebook]
== What is Notebook.ai?
diff --git a/STREAM_REDESIGN_SUMMARY.md b/STREAM_REDESIGN_SUMMARY.md
new file mode 100644
index 000000000..544d3eb0b
--- /dev/null
+++ b/STREAM_REDESIGN_SUMMARY.md
@@ -0,0 +1,78 @@
+# Stream Redesign Summary
+
+## 🎨 Professional, Minimalist, and Fun Redesign Complete!
+
+The social activity stream has been completely redesigned with a modern, professional aesthetic that maintains fun and engaging elements.
+
+### ✅ Key Improvements Made
+
+#### **Visual Design**
+- **Glass morphism effects** - Frosted glass header with backdrop blur
+- **Gradient backgrounds** - Subtle gradients from indigo to purple throughout
+- **Professional color palette** - Indigo, purple, and pink accents with clean grays
+- **Enhanced typography** - Better font weights, sizing, and spacing hierarchy
+- **Rounded corners** - Modern 2xl border radius for cards and components
+
+#### **Interactive Elements**
+- **Smooth animations** - Hover effects, scale transforms, and smooth transitions
+- **Gradient buttons** - Eye-catching CTAs with shadow effects and hover states
+- **Interactive cards** - Subtle glow effects on hover, professional shadows
+- **Status indicators** - Online status dots, content type badges
+- **Micro-interactions** - Button hover states, form focus effects
+
+#### **Layout & UX**
+- **Sticky glass header** - Professional navigation that stays visible
+- **Card-based design** - Clean, organized content in beautiful cards
+- **Better spacing** - Generous whitespace and consistent padding
+- **Visual hierarchy** - Clear content organization with dividers and sections
+- **Mobile responsive** - Works beautifully across all device sizes
+
+#### **Technical Fixes**
+- **MaterializeCSS conflicts resolved** - No more double dropdowns
+- **Form styling** - Tailwind-styled selects work properly
+- **JavaScript protection** - Prevents MaterializeCSS initialization on Tailwind components
+
+### 🚀 New Features
+
+#### **Enhanced Share Creation**
+- Beautiful gradient-bordered creation card
+- Better placeholder text and instructions
+- Visual feedback for public sharing
+- Professional form styling
+
+#### **Modern Feed Items**
+- Glass morphism card design
+- Content type badges with brand colors
+- Interactive hover effects
+- Better comment threading
+- Professional interaction buttons (Like, Comment, Share)
+
+#### **Improved Navigation**
+- Icon-enhanced navigation buttons
+- Search with keyboard shortcut indicators
+- Professional toggle states
+- Smooth transitions between views
+
+#### **Professional Empty States**
+- Beautiful empty state illustrations
+- Encouraging call-to-action buttons
+- Context-appropriate messaging
+
+### 🎯 Design Principles Applied
+
+1. **Professional** - Clean lines, consistent spacing, professional typography
+2. **Minimalist** - Removed visual clutter, focused on content
+3. **Fun** - Gradients, animations, playful hover effects
+4. **Modern** - Glass morphism, subtle shadows, rounded corners
+5. **Accessible** - Good contrast, clear hierarchy, readable fonts
+
+### 💫 Visual Effects Used
+
+- **Backdrop blur filters** for glass effects
+- **CSS gradients** for backgrounds and buttons
+- **Box shadows** with color tinting
+- **Transform animations** for hover states
+- **Opacity transitions** for smooth interactions
+- **Border radius** for modern appearance
+
+The stream now feels like a premium, professional social platform while maintaining the creative and fun nature that makes Notebook.ai special!
\ No newline at end of file
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index b16e53d6d..7bd6f1111 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,3 +1,6 @@
//= link_tree ../images
//= link_directory ../javascripts .js
//= link_directory ../stylesheets .css
+//= link preload/jquery-3.1.1.min.js
+//= link Chart.bundle.js
+//= link chartkick.js
diff --git a/app/assets/images/card-headers/books.jpg b/app/assets/images/card-headers/books.jpg
new file mode 100644
index 000000000..31da2d5e5
Binary files /dev/null and b/app/assets/images/card-headers/books.jpg differ
diff --git a/app/assets/images/card-headers/books.webp b/app/assets/images/card-headers/books.webp
new file mode 100644
index 000000000..ff38926c4
Binary files /dev/null and b/app/assets/images/card-headers/books.webp differ
diff --git a/app/assets/images/landing/digital-notebook-active.mp4 b/app/assets/images/landing/digital-notebook-active.mp4
new file mode 100644
index 000000000..48e25252a
Binary files /dev/null and b/app/assets/images/landing/digital-notebook-active.mp4 differ
diff --git a/app/assets/images/landing/notebook-hero-1-1440.webp b/app/assets/images/landing/notebook-hero-1-1440.webp
new file mode 100644
index 000000000..b810dea08
Binary files /dev/null and b/app/assets/images/landing/notebook-hero-1-1440.webp differ
diff --git a/app/assets/images/landing/notebook-hero-1-1920.webp b/app/assets/images/landing/notebook-hero-1-1920.webp
new file mode 100644
index 000000000..14ffb204f
Binary files /dev/null and b/app/assets/images/landing/notebook-hero-1-1920.webp differ
diff --git a/app/assets/images/landing/notebook-hero-1-828.webp b/app/assets/images/landing/notebook-hero-1-828.webp
new file mode 100644
index 000000000..e94718c9a
Binary files /dev/null and b/app/assets/images/landing/notebook-hero-1-828.webp differ
diff --git a/app/assets/images/landing/notebook-hero-1.png b/app/assets/images/landing/notebook-hero-1.png
new file mode 100644
index 000000000..8e574bb6f
Binary files /dev/null and b/app/assets/images/landing/notebook-hero-1.png differ
diff --git a/app/assets/images/landing/notebook-hero-2.png b/app/assets/images/landing/notebook-hero-2.png
new file mode 100644
index 000000000..a6d1fcb25
Binary files /dev/null and b/app/assets/images/landing/notebook-hero-2.png differ
diff --git a/app/assets/images/tristan/face.png b/app/assets/images/tristan/face.png
new file mode 100644
index 000000000..8e1285b24
Binary files /dev/null and b/app/assets/images/tristan/face.png differ
diff --git a/app/assets/images/tristan/portrait.png b/app/assets/images/tristan/portrait.png
new file mode 100644
index 000000000..fc24d7d93
Binary files /dev/null and b/app/assets/images/tristan/portrait.png differ
diff --git a/app/assets/javascripts/_initialization.js b/app/assets/javascripts/_initialization.js
deleted file mode 100644
index 0c340f552..000000000
--- a/app/assets/javascripts/_initialization.js
+++ /dev/null
@@ -1,28 +0,0 @@
-//# This file is prepended with an underscore to ensure it comes alphabetically-first
-//# when application.js includes all JS files in the directory with require_tree.
-//# Here be dragons.
-
-if (!window.Notebook) { window.Notebook = {}; }
-Notebook.init = function() {
- // Initialize MaterializeCSS stuff
- M.AutoInit();
- $('.sidenav').sidenav();
- $('.quick-reference-sidenav').sidenav({
- closeOnClick: true,
- edge: 'right',
- draggable: false
- });
- $('#recent-edits-sidenav').sidenav({
- closeOnClick: true,
- edge: 'right',
- draggable: false
- });
- $('.slider').slider({ height: 200, indicators: false });
- $('.dropdown-trigger').dropdown({ coverTrigger: false });
- $('.dropdown-trigger-on-hover').dropdown({ coverTrigger: false, hover: true });
- $('.tooltipped').tooltip({ enterDelay: 50 });
- $('.with-character-counter').characterCounter();
- $('.materialboxed').materialbox();
-};
-
-$(() => Notebook.init());
diff --git a/app/assets/javascripts/alpine.js b/app/assets/javascripts/alpine.js
new file mode 100644
index 000000000..c37d41081
--- /dev/null
+++ b/app/assets/javascripts/alpine.js
@@ -0,0 +1,128 @@
+function alpineMultiSelectController() {
+ return {
+ optgroups: [],
+ options: [],
+ selected: [],
+ show: false,
+ sourceFieldId: '',
+ searchQuery: '',
+ open() {
+ this.show = true;
+ // Focus search input after dropdown opens
+ this.$nextTick(() => {
+ if (this.$refs.searchInput) {
+ this.$refs.searchInput.focus();
+ }
+ });
+ },
+ close() { this.show = false },
+ isOpen() { return this.show === true },
+ filterOptions() {
+ // Update filteredOptions for each optgroup based on search query
+ this.optgroups.forEach(optgroup => {
+ if (!this.searchQuery || this.searchQuery.trim() === '') {
+ optgroup.filteredOptions = optgroup.options;
+ } else {
+ const query = this.searchQuery.toLowerCase();
+ optgroup.filteredOptions = optgroup.options.filter(option =>
+ option.text.toLowerCase().includes(query)
+ );
+ }
+ });
+ },
+ select(index, event) {
+ if (!this.options[index].selected) {
+ this.options[index].selected = true;
+ this.options[index].element = event.target;
+ this.selected.push(index);
+ } else {
+ this.selected.splice(this.selected.lastIndexOf(index), 1);
+ this.options[index].selected = false;
+ }
+ this.syncSelect();
+ },
+ remove(index, option) {
+ this.options[option].selected = false;
+ this.selected.splice(index, 1);
+ this.syncSelect();
+ },
+ syncSelect() {
+ const originalSelect = document.getElementById(this.sourceFieldId);
+ originalSelect.innerHTML = '';
+
+ this.options.filter(opt => opt.selected).forEach(opt => {
+ const optionEl = document.createElement('option');
+ optionEl.value = opt.value;
+ optionEl.text = opt.text;
+ optionEl.selected = true;
+ originalSelect.appendChild(optionEl);
+ });
+
+ $(originalSelect).trigger('change');
+ },
+ loadOptions(fieldId, validTypes) {
+ this.sourceFieldId = fieldId;
+ const select = document.getElementById(fieldId);
+
+ const preSelectedValues = Array.from(select.options).map(opt => opt.value);
+
+ let runningOptionIndex = 0;
+ const grouped = {};
+ const allLinkables = window.notebookLinkables || [];
+
+ allLinkables.forEach(linkable => {
+ if (validTypes && !validTypes.includes(linkable.type)) {
+ return;
+ }
+
+ if (!grouped[linkable.type]) {
+ grouped[linkable.type] = [];
+ }
+
+ const isSelected = preSelectedValues.includes(linkable.raw_value);
+
+ const optionData = {
+ index: runningOptionIndex++,
+ value: linkable.raw_value,
+ text: linkable.name,
+ imageUrl: linkable.imageUrl || '',
+ icon: linkable.icon,
+ icon_color: linkable.textColor,
+ selected: isSelected
+ };
+
+ grouped[linkable.type].push(optionData);
+ this.options.push(optionData);
+
+ if (isSelected) {
+ this.selected.push(optionData.index);
+ }
+ });
+
+ Object.keys(grouped).forEach(type => {
+ const typeOptions = grouped[type];
+ if (typeOptions.length > 0) {
+ let typeData = window.ContentTypeData ? window.ContentTypeData[type] : null;
+
+ this.optgroups.push({
+ label: type,
+ icon: typeData ? typeData.icon : typeOptions[0].icon,
+ color: typeData ? typeData.color : '',
+ textColor: typeData ? typeData.text_color : typeOptions[0].icon_color,
+ iconColor: typeData ? typeData.text_color : typeOptions[0].icon_color,
+ plural: typeData ? typeData.plural : type + 's',
+ options: typeOptions,
+ filteredOptions: typeOptions
+ });
+ }
+ });
+ },
+ selectedValues(){
+ // Return all this.options where selected=true
+ return this.options.filter(op => op.selected === true); // .map(el => el.text)
+ // return this.selected.map((option)=>{
+ // return this.options[option].value;
+ // });
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 056245db4..125064395 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -13,10 +13,10 @@
//= require_tree ./preload
//= require cocoon
//= require Chart.bundle
+//= require chartjs-plugin-annotation.min
//= require chartkick
//= require autocomplete-rails
-//= require tribute
+// require tribute - Replaced with Alpine.js implementation
//= require d3
+//= require tailwind_initialization
//= require_tree .
-
-
diff --git a/app/assets/javascripts/attribute_editor.js.erb b/app/assets/javascripts/attribute_editor.js.erb
index 0d249b8e4..2d7e2057f 100644
--- a/app/assets/javascripts/attribute_editor.js.erb
+++ b/app/assets/javascripts/attribute_editor.js.erb
@@ -1,4 +1,10 @@
$(document).ready(function() {
+ // Check if iconpicker plugin is available
+ if (typeof $.fn.iconpicker === 'undefined') {
+ console.warn('iconpicker plugin not loaded, skipping iconpicker initialization');
+ return;
+ }
+
$('.iconpicker-input').iconpicker({
icons: [
<% MATERIAL_ICONS.each do |icon_name| %>
diff --git a/app/assets/javascripts/attributes_editor.js b/app/assets/javascripts/attributes_editor.js
index d04f98c3c..85cbfc4b7 100644
--- a/app/assets/javascripts/attributes_editor.js
+++ b/app/assets/javascripts/attributes_editor.js
@@ -5,8 +5,23 @@ $(document).ready(function () {
$.ajax({
dataType: "json",
- url: "/api/v1/categories/suggest/" + content_type,
+ url: "/plan/attribute_categories/suggest?content_type=" + content_type,
success: function (data) {
+ console.log('Categories suggestion data received:', data);
+ console.log('Data type:', typeof data);
+ console.log('Is array:', Array.isArray(data));
+
+ // If data is a string, try to parse it as JSON
+ if (typeof data === 'string') {
+ try {
+ data = JSON.parse(data);
+ console.log('Parsed data:', data);
+ } catch (e) {
+ console.error('Failed to parse JSON:', e);
+ return;
+ }
+ }
+
var existing_categories = $('.js-category-label').map(function(){
return $.trim($(this).text());
}).get();
@@ -45,7 +60,7 @@ $(document).ready(function () {
$.ajax({
dataType: "json",
- url: "/api/v1/fields/suggest/" + content_type + "/" + category_label,
+ url: "/plan/attribute_fields/suggest?content_type=" + content_type + "&category=" + category_label,
success: function (data) {
// console.log("new fields");
// console.log(data);
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
deleted file mode 100644
index 24a86364d..000000000
--- a/app/assets/javascripts/autosave.js
+++ /dev/null
@@ -1,36 +0,0 @@
-$(document).ready(function() {
- $('.autosave-closest-form-on-change').change(function () {
- var content_form = $(this).closest('form');
-
- if (content_form) {
- M.toast({ html: 'Saving your changes...' });
-
- var form_data = content_form.serialize();
- form_data += "&authenticity_token=" + $('meta[name="csrf-token"]').attr('content');
-
- $.ajax({
- url: content_form.attr('action') + '.json',
- type: content_form.attr('method').toUpperCase(),
- data: form_data,
- success: function(response) {
- M.toast({ html: 'Saved successfully!' });
- },
- error: function(response) {
- M.toast({ html: "There was an error saving your changes. Please back up any changes and refresh the page." });
- }
- });
- } else {
- M.toast({ html: "There was an error saving your changes. Please back up any changes and refresh the page." });
- }
- });
-
- $('.submit-closest-form-on-click').on('click', function() {
- $(this).closest('form').submit();
- })
-
- // To ensure all fields get unblurred (and therefore autosaved) upon navigation,
- // we use this little ditty:
- window.onbeforeunload = function(e){
- $(document.activeElement).blur();
- }
-});
diff --git a/app/assets/javascripts/autosize-textareas.js b/app/assets/javascripts/autosize-textareas.js
new file mode 100644
index 000000000..10646f334
--- /dev/null
+++ b/app/assets/javascripts/autosize-textareas.js
@@ -0,0 +1,22 @@
+$(document).ready(function() {
+ const yPadding = 16;
+ const lineHeight = 20; // 36
+ const minLines = 3; // Minimum number of lines to display
+ const minHeight = yPadding + (minLines * lineHeight); // Minimum height for 3 lines
+
+ const elements = document.getElementsByClassName('js-autosize-textarea');
+ for (let i = 0; i < elements.length; i++) {
+ // Set the initial height of the textarea
+ const linesCount = Math.max(elements[i].value.split("\n").length, minLines);
+ const contentHeight = yPadding + (linesCount * lineHeight);
+ elements[i].setAttribute("style", "height:" + Math.max(contentHeight, minHeight) + "px;overflow-y:hidden;");
+
+ // Resize the textarea whenever the value changes
+ elements[i].addEventListener("input", OnInput, false);
+ }
+
+ function OnInput() {
+ this.style.height = minHeight + "px";
+ this.style.height = Math.max(this.scrollHeight, minHeight) + "px";
+ }
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/basil.coffee b/app/assets/javascripts/basil.coffee
deleted file mode 100644
index 24f83d18b..000000000
--- a/app/assets/javascripts/basil.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://coffeescript.org/
diff --git a/app/assets/javascripts/collection_wizard.js b/app/assets/javascripts/collection_wizard.js
new file mode 100644
index 000000000..420fa70bc
--- /dev/null
+++ b/app/assets/javascripts/collection_wizard.js
@@ -0,0 +1,391 @@
+// Collection Creation Wizard Controller
+document.addEventListener('DOMContentLoaded', function() {
+ // Only initialize if we're on the collection form page
+ const collectionForm = document.getElementById('collection-form');
+ if (!collectionForm) return;
+
+ let currentStep = 1;
+ const totalSteps = 4;
+
+ // Initialize wizard
+ initializeWizard();
+
+ function initializeWizard() {
+ // Set up step navigation
+ setupStepNavigation();
+
+ // Set up form interactions
+ setupFormInteractions();
+
+
+ // Set up content type selection
+ setupContentTypeSelection();
+
+ // Set up theme selection
+ setupThemeSelection();
+
+ // Initialize preview
+ updatePreview();
+
+ // Update progress
+ updateProgress();
+ }
+
+ function setupStepNavigation() {
+ // Step navigation buttons - allow free navigation between steps
+ document.querySelectorAll('.step-nav').forEach(button => {
+ if (button) {
+ button.addEventListener('click', function() {
+ const targetStep = parseInt(this.dataset.step);
+ goToStep(targetStep);
+ });
+ }
+ });
+
+ // Next/Previous buttons
+ document.querySelectorAll('.next-step').forEach(button => {
+ if (button) {
+ button.addEventListener('click', function() {
+ if (validateCurrentStep()) {
+ nextStep();
+ }
+ });
+ }
+ });
+
+ document.querySelectorAll('.prev-step').forEach(button => {
+ if (button) {
+ button.addEventListener('click', function() {
+ previousStep();
+ });
+ }
+ });
+ }
+
+ function setupFormInteractions() {
+ // Real-time form validation and preview updates
+ const formInputs = document.querySelectorAll('#collection-form input, #collection-form textarea, #collection-form select');
+
+ formInputs.forEach(input => {
+ if (input) {
+ input.addEventListener('input', function() {
+ updatePreview();
+ validateCurrentStep();
+ });
+
+ input.addEventListener('change', function() {
+ updatePreview();
+ validateCurrentStep();
+ });
+ }
+ });
+
+ // Character counter for description
+ const descriptionField = document.querySelector('textarea[name="page_collection[description]"]');
+ const charCounter = document.getElementById('char-count');
+
+ if (descriptionField && charCounter) {
+ descriptionField.addEventListener('input', function() {
+ updateCharCount();
+ });
+ updateCharCount();
+ }
+
+ function updateCharCount() {
+ const count = descriptionField.value.length;
+ charCounter.textContent = `${count} / 500`;
+
+ if (count > 450) {
+ charCounter.classList.add('text-red-600');
+ charCounter.classList.remove('text-amber-600');
+ } else if (count > 400) {
+ charCounter.classList.add('text-amber-600');
+ charCounter.classList.remove('text-red-600');
+ } else {
+ charCounter.classList.remove('text-amber-600', 'text-red-600');
+ }
+ }
+ }
+
+
+ function setupContentTypeSelection() {
+ const selectAllBtn = document.getElementById('select-all-types');
+ const selectNoneBtn = document.getElementById('select-none-types');
+ const selectCommonBtn = document.getElementById('select-common-types');
+ const checkboxes = document.querySelectorAll('.content-type-checkbox');
+
+ if (selectAllBtn) {
+ selectAllBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = true;
+ updateCheckboxUI(checkbox);
+ });
+ updatePreview();
+ });
+ }
+
+ if (selectNoneBtn) {
+ selectNoneBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = false;
+ updateCheckboxUI(checkbox);
+ });
+ updatePreview();
+ });
+ }
+
+ if (selectCommonBtn) {
+ selectCommonBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = checkbox.dataset.common === 'true';
+ updateCheckboxUI(checkbox);
+ });
+ updatePreview();
+ });
+ }
+
+ // Update checkbox UI when changed
+ checkboxes.forEach(checkbox => {
+ checkbox.addEventListener('change', function() {
+ updateCheckboxUI(this);
+ updatePreview();
+ });
+ // Initialize UI
+ updateCheckboxUI(checkbox);
+ });
+ }
+
+ function updateCheckboxUI(checkbox) {
+ const label = checkbox.closest('label');
+ const customCheckbox = label.querySelector('.checkbox-custom');
+ const checkIcon = label.querySelector('.check-icon');
+
+ if (checkbox.checked) {
+ customCheckbox.classList.add('bg-blue-500', 'border-blue-500');
+ customCheckbox.classList.remove('border-gray-300');
+ checkIcon.classList.remove('opacity-0');
+ checkIcon.classList.add('opacity-100');
+ label.classList.add('ring-2', 'ring-blue-500', 'ring-opacity-20');
+ } else {
+ customCheckbox.classList.remove('bg-blue-500', 'border-blue-500');
+ customCheckbox.classList.add('border-gray-300');
+ checkIcon.classList.add('opacity-0');
+ checkIcon.classList.remove('opacity-100');
+ label.classList.remove('ring-2', 'ring-blue-500', 'ring-opacity-20');
+ }
+ }
+
+ function setupThemeSelection() {
+ const themeOptions = document.querySelectorAll('.theme-option');
+
+ themeOptions.forEach(option => {
+ option.addEventListener('click', function() {
+ // Remove active class from all options
+ themeOptions.forEach(opt => opt.classList.remove('active'));
+
+ // Add active class to clicked option
+ this.classList.add('active');
+
+ // Update preview theme
+ updatePreviewTheme(this.dataset.theme);
+ });
+ });
+ }
+
+ function updatePreviewTheme(theme) {
+ const previewHeader = document.getElementById('preview-header');
+ if (!previewHeader) return;
+
+ // Remove existing theme classes
+ previewHeader.className = previewHeader.className.replace(/from-\w+-\d+|to-\w+-\d+/g, '');
+
+ // Apply new theme
+ switch(theme) {
+ case 'warm':
+ previewHeader.classList.add('from-orange-500', 'to-red-600');
+ break;
+ case 'nature':
+ previewHeader.classList.add('from-green-500', 'to-emerald-600');
+ break;
+ case 'monochrome':
+ previewHeader.classList.add('from-gray-500', 'to-gray-800');
+ break;
+ default:
+ previewHeader.classList.add('from-blue-500', 'to-purple-600');
+ }
+ }
+
+ function goToStep(stepNumber) {
+ if (stepNumber < 1 || stepNumber > totalSteps) return;
+
+ // Hide all steps
+ document.querySelectorAll('.form-step').forEach(step => {
+ step.classList.remove('active');
+ });
+
+ // Show target step
+ const targetStep = document.getElementById(`step-${stepNumber}`);
+ if (targetStep) {
+ targetStep.classList.add('active');
+ }
+
+ // Update step navigation appearance
+ document.querySelectorAll('.step-nav').forEach(nav => {
+ nav.classList.remove('active', 'completed');
+ });
+
+ // Mark current step as active
+ const currentNav = document.querySelector(`[data-step="${stepNumber}"]`);
+ if (currentNav) {
+ currentNav.classList.add('active');
+ }
+
+ // Mark completed steps (steps that have valid data)
+ updateStepCompletionStatus();
+
+ currentStep = stepNumber;
+ updateProgress();
+ }
+
+ function updateStepCompletionStatus() {
+ // Step 1: Basic info
+ if (validateBasicInfo()) {
+ const step1Nav = document.querySelector('[data-step="1"]');
+ if (step1Nav && !step1Nav.classList.contains('active')) {
+ step1Nav.classList.add('completed');
+ }
+ }
+
+ // Step 3: Content types
+ if (validateContentTypes()) {
+ const step3Nav = document.querySelector('[data-step="3"]');
+ if (step3Nav && !step3Nav.classList.contains('active')) {
+ step3Nav.classList.add('completed');
+ }
+ }
+
+ // Step 4: Settings
+ if (validateSettings()) {
+ const step4Nav = document.querySelector('[data-step="4"]');
+ if (step4Nav && !step4Nav.classList.contains('active')) {
+ step4Nav.classList.add('completed');
+ }
+ }
+ }
+
+ function nextStep() {
+ if (currentStep < totalSteps) {
+ goToStep(currentStep + 1);
+ }
+ }
+
+ function previousStep() {
+ if (currentStep > 1) {
+ goToStep(currentStep - 1);
+ }
+ }
+
+ function validateCurrentStep() {
+ switch(currentStep) {
+ case 1:
+ return validateBasicInfo();
+ case 2:
+ return true; // Visual design is optional
+ case 3:
+ return validateContentTypes();
+ case 4:
+ return validateSettings();
+ default:
+ return true;
+ }
+ }
+
+ function validateBasicInfo() {
+ const titleInput = document.querySelector('input[name="page_collection[title]"]');
+ return titleInput && titleInput.value.trim().length > 0;
+ }
+
+ function validateContentTypes() {
+ const checkboxes = document.querySelectorAll('.content-type-checkbox');
+ return Array.from(checkboxes).some(checkbox => checkbox.checked);
+ }
+
+ function validateSettings() {
+ const privacyRadios = document.querySelectorAll('input[name="page_collection[privacy]"]');
+ return Array.from(privacyRadios).some(radio => radio.checked);
+ }
+
+ function getCompletedSteps() {
+ let completed = 0;
+ if (validateBasicInfo()) completed = Math.max(completed, 1);
+ if (validateContentTypes()) completed = Math.max(completed, 3);
+ if (validateSettings()) completed = Math.max(completed, 4);
+ return completed;
+ }
+
+ function updateProgress() {
+ const progressBar = document.getElementById('progress-bar');
+ const progressText = document.getElementById('progress-text');
+
+ if (progressBar && progressText) {
+ const progress = (currentStep / totalSteps) * 100;
+ progressBar.style.width = `${progress}%`;
+ progressText.textContent = `Step ${currentStep} of ${totalSteps}`;
+ }
+ }
+
+ function updatePreview() {
+ if (window.updateCollectionPreview) {
+ window.updateCollectionPreview();
+ }
+ }
+
+ // Initialize toggle functionality
+ initializeToggles();
+
+ function initializeToggles() {
+ const submissionToggle = document.querySelector('.submission-toggle');
+ const autoAcceptToggle = document.querySelector('.auto-accept-toggle');
+ const autoAcceptSetting = document.getElementById('auto-accept-setting');
+
+ // Set up submission toggle
+ if (submissionToggle) {
+ submissionToggle.addEventListener('change', function() {
+ if (autoAcceptSetting) {
+ if (this.checked) {
+ autoAcceptSetting.classList.remove('hidden');
+ } else {
+ autoAcceptSetting.classList.add('hidden');
+ // Also uncheck auto-accept when submissions are disabled
+ if (autoAcceptToggle) {
+ autoAcceptToggle.checked = false;
+ }
+ }
+ }
+ updatePreview();
+ });
+ }
+
+ // Set up auto-accept toggle
+ if (autoAcceptToggle) {
+ autoAcceptToggle.addEventListener('change', function() {
+ updatePreview();
+ });
+ }
+ }
+
+ // Keyboard navigation
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'ArrowRight' && e.ctrlKey) {
+ e.preventDefault();
+ if (validateCurrentStep()) nextStep();
+ } else if (e.key === 'ArrowLeft' && e.ctrlKey) {
+ e.preventDefault();
+ previousStep();
+ }
+ });
+
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/content.js b/app/assets/javascripts/content.js
index 18978fc58..de5260f14 100644
--- a/app/assets/javascripts/content.js
+++ b/app/assets/javascripts/content.js
@@ -34,10 +34,19 @@ $(document).ready(function () {
return false;
});
- $('.modal').modal();
+ // Check if modal plugin is available
+ if (typeof $.fn.modal !== 'undefined') {
+ $('.modal').modal();
+ } else {
+ console.warn('modal plugin not loaded, skipping modal initialization');
+ }
$('.share').click(function () {
- $('#share-modal').modal('open');
+ if (typeof $.fn.modal !== 'undefined') {
+ $('#share-modal').modal('open');
+ } else {
+ console.warn('modal plugin not loaded, cannot open share modal');
+ }
});
$('.expand').click(function () {
diff --git a/app/assets/javascripts/content_type_data.js.erb b/app/assets/javascripts/content_type_data.js.erb
index 6a4ebc7a0..697646957 100644
--- a/app/assets/javascripts/content_type_data.js.erb
+++ b/app/assets/javascripts/content_type_data.js.erb
@@ -1,9 +1,10 @@
window.ContentTypeData = {
<% (Rails.application.config.content_types[:all] + [Document, Timeline]).each do |content_type| %>
'<%= content_type.name %>': {
- color: '<%= content_type.color %>',
- hex: '<%= content_type.hex_color %>',
- icon: '<%= content_type.icon %>'
+ color: '<%= content_type.color %>', // comment to bust cache :) -- should be safe to remove after release but double check the link dropdown optgroup bg colors when you do
+ hex: '<%= content_type.hex_color %>',
+ icon: '<%= content_type.icon %>',
+ plural: '<%= content_type.name.pluralize %>'
},
<% end %>
};
\ No newline at end of file
diff --git a/app/assets/javascripts/content_types.js b/app/assets/javascripts/content_types.js
index 83fd6f2bc..25e17f656 100644
--- a/app/assets/javascripts/content_types.js
+++ b/app/assets/javascripts/content_types.js
@@ -1,24 +1,23 @@
-$(document).ready(function() {
- $('.js-enable-content-type').click(function () {
- var content_type = $(this).data('content-type');
- var related_card = $(this).children('.card').first();
- var is_currently_active = related_card.hasClass('active');
- var ie_badge = $(this).find('.enabled-badge');
-
- $.post('/customization/toggle_content_type', {
+// x-on:click="enabled = !enabled; togglePageType('Character', enabled)"
+function togglePageType(content_type, active) {
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
+ fetch('/customization/toggle_content_type', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken
+ },
+ body: JSON.stringify({
content_type: content_type,
- active: is_currently_active ? 'off' : 'on'
- });
-
- if (is_currently_active) {
- related_card.removeClass('active');
- ie_badge.attr('data-badge-caption', 'hidden');
- } else {
- related_card.addClass('active');
- ie_badge.attr('data-badge-caption', 'active');
- }
-
- // Return false so we don't jump to the top of the page on link click
- return false;
+ active: active ? 'on' : 'off'
+ })
+ })
+ .then(response => {
+ // Handle the response
+ console.log('toggled successfully');
+ })
+ .catch(error => {
+ // Handle the error
+ console.log('couldnt toggle');
});
-});
+}
diff --git a/app/assets/javascripts/dark-mode.js b/app/assets/javascripts/dark-mode.js
index d64190be9..92150deca 100644
--- a/app/assets/javascripts/dark-mode.js
+++ b/app/assets/javascripts/dark-mode.js
@@ -1,8 +1,8 @@
-$(document).ready(function(){
- $('.dark-toggle').on('click', function() {
+$(document).ready(function () {
+ $('.dark-toggle').on('click', function () {
var toggle_icon = $(this).find('i');
var light_mode_icon = 'brightness_high',
- dark_mode_icon = 'brightness_4';
+ dark_mode_icon = 'brightness_4';
var dark_mode_enabled = $('body').hasClass('dark');
window.localStorage.setItem('dark_mode_enabled', !dark_mode_enabled);
@@ -10,16 +10,18 @@ $(document).ready(function(){
if (dark_mode_enabled) {
$('body').removeClass('dark');
+ $('html').removeClass('dark');
} else {
$('body').addClass('dark');
+ $('html').addClass('dark');
}
// Update dark mode preferences server-side so we can enable it at page load
// and avoid any light-to-dark blinding flashes
$.ajax({
- type: "PUT",
+ type: "PUT",
dataType: "json",
- url: '/users',
+ url: '/users',
data: {
'user': {
'dark_mode_enabled': !dark_mode_enabled
diff --git a/app/assets/javascripts/document_editor.js b/app/assets/javascripts/document_editor.js
deleted file mode 100644
index 8e4e2a2bc..000000000
--- a/app/assets/javascripts/document_editor.js
+++ /dev/null
@@ -1,128 +0,0 @@
-Notebook.DocumentEditor = class DocumentEditor {
- constructor(el) {
- this.el = el;
- if (!(this.el.length > 0)) { return; }
-
- window.editor = new MediumEditor('#editor', {
- targetBlank: true,
- autoLink: false,
- buttonLabels: 'fontawesome',
- toolbar: { buttons: [
- 'bold',
- 'italic',
- 'underline',
- 'strikethrough',
- {
- name: 'h1',
- action: 'append-h2',
- aria: 'header type 1',
- tagNames: [ 'h2' ],
- contentDefault: 'H1',
- classList: [ 'custom-class-h1' ],
- attrs: { 'data-custom-attr': 'attr-value-h1'
- }
- },
- {
- name: 'h2',
- action: 'append-h3',
- aria: 'header type 2',
- tagNames: [ 'h3' ],
- contentDefault: 'H2',
- classList: [ 'custom-class-h2' ],
- attrs: { 'data-custom-attr': 'attr-value-h2'
- }
- },
- {
- name: 'h3',
- action: 'append-h4',
- aria: 'header type 3',
- tagNames: [ 'h4' ],
- contentDefault: 'H3',
- classList: [ 'custom-class-h3' ],
- attrs: { 'data-custom-attr': 'attr-value-h3'
- }
- },
- 'justifyLeft',
- 'justifyCenter',
- 'justifyRight',
- 'justifyFull',
- 'orderedlist',
- 'unorderedlist',
- 'quote',
- 'anchor',
- 'removeFormat'
- ]
- },
- anchorPreview: { hideDelay: 0
- },
- placeholder: { text: 'Write as little or as much as you want!'
- },
- paste: { forcePlainText: false
- }
- });
-
- // Autosave
- let autosave_event = null;
- let last_autosave = null;
-
- const autosave = function() {
- if (autosave_event === null) {
-
- console.log('Queueing autosave');
- $('.js-autosave-icon').addClass('grey-text');
- $('.js-autosave-icon').removeClass('black-text');
- $('.js-autosave-icon').removeClass('red-text');
- $('.js-autosave-status').text('Saving changes...');
-
- autosave_event = setTimeout((function() {
- console.log('Autosaving...');
- $('.js-autosave-status').text('Saving...');
- autosave_event = null;
-
- // Do the autosave
- last_autosave = $.ajax({
- type: 'PATCH',
- url: $('#editor').data('save-url'),
- data: { document: {
- title: $('#document_title').val(),
- body: $('#editor').html()
- }
- }
- });
-
- last_autosave.fail(function(jqXHR, textStatus) {
- $('.js-autosave-status').text('There was a problem saving! We will try to save again, but please make sure you back up any changes.');
- $('.js-autosave-status').addClass('red-text');
- $('.js-autosave-status').removeClass('grey-text');
- $('.js-autosave-status').removeClass('black-text');
- });
-
- // Done!
- $('.js-autosave-icon').addClass('black-text');
- $('.js-autosave-icon').removeClass('grey-text');
- $('.js-autosave-icon').removeClass('red-text');
- $('.js-autosave-status').text('Saved!');
-
- }), 2500);
-
- } else {
- console.log('Waiting for existing autosave');
- }
- };
-
- editor.subscribe('editableInput', autosave);
- $('#document_title').on('change', autosave);
- $('#document_title').on('keydown', autosave);
- $('.js-autosave-status').on('click', autosave);
-
- // Allow entering `tab` into the editor
- $(document).delegate('#editor', 'keydown', function(e) {
- const keyCode = e.keyCode || e.which;
- if (keyCode === 9) {
- e.preventDefault();
- }
- });
- }
-};
-
-$(() => new Notebook.DocumentEditor($("body.documents.edit")));
diff --git a/app/assets/javascripts/document_editor_tailwind.js b/app/assets/javascripts/document_editor_tailwind.js
new file mode 100644
index 000000000..bb028e984
--- /dev/null
+++ b/app/assets/javascripts/document_editor_tailwind.js
@@ -0,0 +1,398 @@
+// Document Editor JavaScript
+// Medium Editor initialization, autosave, and metadata modal handling.
+// For use with the Tailwind CSS-based document editor.
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Only initialize if we're on the document edit page
+ const editorElement = document.getElementById('editor');
+ if (!editorElement) return;
+
+ // Initialize Medium Editor
+ window.editor = new MediumEditor('#editor', {
+ targetBlank: true,
+ autoLink: false,
+ buttonLabels: 'fontawesome',
+ placeholder: {
+ text: 'Write as little or as much as you want!',
+ hideOnClick: false
+ },
+ toolbar: {
+ buttons: [
+ 'bold',
+ 'italic',
+ 'underline',
+ 'strikethrough',
+ {
+ name: 'h1',
+ action: 'append-h2',
+ aria: 'header type 1',
+ tagNames: ['h2'],
+ contentDefault: 'H1',
+ classList: ['custom-class-h1'],
+ attrs: {'data-custom-attr': 'attr-value-h1'}
+ },
+ {
+ name: 'h2',
+ action: 'append-h3',
+ aria: 'header type 2',
+ tagNames: ['h3'],
+ contentDefault: 'H2',
+ classList: ['custom-class-h2'],
+ attrs: {'data-custom-attr': 'attr-value-h2'}
+ },
+ {
+ name: 'h3',
+ action: 'append-h4',
+ aria: 'header type 3',
+ tagNames: ['h4'],
+ contentDefault: 'H3',
+ classList: ['custom-class-h3'],
+ attrs: {'data-custom-attr': 'attr-value-h3'}
+ },
+ 'justifyLeft',
+ 'justifyCenter',
+ 'justifyRight',
+ 'justifyFull',
+ 'orderedlist',
+ 'unorderedlist',
+ 'quote',
+ 'anchor',
+ 'removeFormat'
+ ]
+ },
+ anchorPreview: {
+ hideDelay: 0
+ },
+ paste: {
+ forcePlainText: false
+ }
+ });
+
+ // Strip background colors when copying to prevent dark mode backgrounds in clipboard
+ editorElement.addEventListener('copy', function(e) {
+ const selection = window.getSelection();
+ if (!selection.rangeCount) return;
+
+ // Get the selected HTML
+ const range = selection.getRangeAt(0);
+ const div = document.createElement('div');
+ div.appendChild(range.cloneContents());
+
+ // Remove background colors from all elements
+ div.querySelectorAll('*').forEach(function(el) {
+ el.style.removeProperty('background-color');
+ el.style.removeProperty('background');
+ });
+
+ // Also clean inline style attributes that may contain background
+ const html = div.innerHTML.replace(/background(-color)?:\s*[^;]+;?/gi, '');
+
+ // Set the cleaned content on the clipboard
+ e.clipboardData.setData('text/html', html);
+ e.clipboardData.setData('text/plain', selection.toString());
+ e.preventDefault();
+ });
+
+ // Add tooltips to toolbar buttons
+ setTimeout(function() {
+ const tooltips = {
+ 'bold': 'Bold (Ctrl+B)',
+ 'italic': 'Italic (Ctrl+I)',
+ 'underline': 'Underline (Ctrl+U)',
+ 'strikethrough': 'Strikethrough',
+ 'append-h2': 'Heading 1',
+ 'append-h3': 'Heading 2',
+ 'append-h4': 'Heading 3',
+ 'justifyLeft': 'Align Left',
+ 'justifyCenter': 'Align Center',
+ 'justifyRight': 'Align Right',
+ 'justifyFull': 'Justify',
+ 'insertorderedlist': 'Numbered List',
+ 'insertunorderedlist': 'Bullet List',
+ 'append-blockquote': 'Block Quote',
+ 'createLink': 'Insert Link (Ctrl+K)',
+ 'removeFormat': 'Clear Formatting'
+ };
+
+ document.querySelectorAll('.medium-editor-action').forEach(function(btn) {
+ const action = btn.getAttribute('data-action');
+ if (tooltips[action]) {
+ btn.setAttribute('title', tooltips[action]);
+ }
+ });
+ }, 100);
+
+ // Set up CSRF token for AJAX requests
+ $.ajaxSetup({
+ headers: {
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
+ }
+ });
+
+ // Autosave variables
+ let short_timer = null; // Timer that resets on each change (2 seconds)
+ let long_timer = null; // Timer that forces save after 30 seconds
+ let last_autosave = null; // Last AJAX request
+ let first_change_time = null; // When the first unsaved change was made
+ let has_unsaved_changes = false; // Whether we have unsaved changes
+ let autosave_hide_timeout = null; // Timeout for hiding success indicator
+
+ // Function to perform the actual save
+ const performSave = function() {
+ console.log('Performing save');
+
+ // Update save indicator to saving state (subtle)
+ $('.js-autosave-status').text('Saving...');
+ $('.js-autosave-icon').text('cloud_upload').addClass('text-gray-400').removeClass('text-gray-500 text-red-400');
+ $('#save-indicator').removeClass('bg-red-500 bg-opacity-80').addClass('bg-gray-100 bg-opacity-80 dark:bg-gray-800 dark:bg-opacity-80 pulse-animation');
+ $('.js-autosave-status').removeClass('text-white').addClass('text-gray-500 dark:text-gray-400');
+
+ // Update mini save indicator
+ $('#mini-save-indicator i').addClass('pulse-animation');
+
+ // Clear both timers
+ clearTimeout(short_timer);
+ clearTimeout(long_timer);
+ short_timer = null;
+ long_timer = null;
+
+ // Reset change tracking
+ first_change_time = null;
+ has_unsaved_changes = false;
+
+ // Do the autosave
+ last_autosave = $.ajax({
+ type: 'PATCH',
+ url: $('#editor').data('save-url'),
+ data: {
+ document: {
+ title: $('#document_title').val(),
+ body: $('#editor').html(),
+ cached_word_count: countWords()
+ }
+ }
+ });
+
+ last_autosave.fail(function(jqXHR, textStatus) {
+ // Update indicator to error state (subtle red)
+ $('.js-autosave-status').text('Error!');
+ $('.js-autosave-icon').text('cloud_off').addClass('text-white').removeClass('text-gray-400 text-gray-500');
+ $('#save-indicator').removeClass('bg-gray-100 bg-opacity-80 dark:bg-gray-800 dark:bg-opacity-80 bg-green-100 bg-opacity-80').addClass('bg-red-500 bg-opacity-90');
+ $('.js-autosave-status').removeClass('text-gray-500 dark:text-gray-400').addClass('text-white');
+
+ // Mark that we still have unsaved changes
+ has_unsaved_changes = true;
+ });
+
+ last_autosave.done(function() {
+ // Update indicator to saved state (subtle)
+ $('.js-autosave-status').text('Saved');
+ $('.js-autosave-icon').text('cloud_done').addClass('text-gray-400 dark:text-gray-500').removeClass('text-white text-red-400');
+ $('#save-indicator').removeClass('bg-red-500 bg-opacity-90 pulse-animation').addClass('bg-gray-100 bg-opacity-80 dark:bg-gray-800 dark:bg-opacity-80');
+ $('.js-autosave-status').removeClass('text-white').addClass('text-gray-500 dark:text-gray-400');
+
+ // Update mini save indicator
+ $('#mini-save-indicator i').removeClass('pulse-animation');
+
+ // Brief subtle green flash for success
+ $('#save-indicator').addClass('bg-green-100 bg-opacity-80 dark:bg-green-900 dark:bg-opacity-30');
+ clearTimeout(autosave_hide_timeout);
+ autosave_hide_timeout = setTimeout(function() {
+ $('#save-indicator').removeClass('bg-green-100 bg-opacity-80 dark:bg-green-900 dark:bg-opacity-30');
+ }, 800);
+ });
+ };
+
+ // Function to queue an autosave after a change
+ const queueAutosave = function() {
+ // If this is the first unsaved change, record the time
+ if (!has_unsaved_changes) {
+ first_change_time = new Date();
+ has_unsaved_changes = true;
+
+ // Set long timer for 30 seconds (won't be reset by further changes)
+ if (!long_timer) {
+ long_timer = setTimeout(function() {
+ console.log('Long timer triggered - 30 seconds since first change');
+ performSave();
+ }, 30000); // 30 seconds
+ }
+ }
+
+ // Update indicator to show pending changes (subtle)
+ $('.js-autosave-status').text('Unsaved');
+ $('.js-autosave-icon').text('edit');
+
+ // Clear and reset the short timer (500ms)
+ clearTimeout(short_timer);
+ short_timer = setTimeout(function() {
+ console.log('Short timer triggered - 500ms since last change');
+ performSave();
+ }, 500); // 500ms
+ };
+
+ // Function to count words - simple whitespace split
+ // Note: Server-side WordCountService is authoritative; this is an approximation for real-time display
+ const countWords = function() {
+ const text = document.getElementById('editor').textContent || '';
+ if (text.length === 0) return 0;
+ return text.trim().split(/\s+/).filter(function(w) { return w.length > 0; }).length;
+ };
+
+ // Function to count characters (uses textContent for efficiency)
+ const countCharacters = function() {
+ const text = document.getElementById('editor').textContent || '';
+ return text.length;
+ };
+
+ // Function to calculate reading time (avg 200 words per minute)
+ const calculateReadingTime = function(wordCount) {
+ const minutes = Math.ceil(wordCount / 200);
+ return minutes < 1 ? '<1' : minutes.toString();
+ };
+
+ // Update word count display
+ const updateWordCount = function() {
+ // Use optimized functions that read textContent directly
+ const wordCount = countWords();
+ const charCount = countCharacters();
+ const readingTime = calculateReadingTime(wordCount);
+
+ // Update navbar word count
+ $('#word-count').text(wordCount);
+ $('#mini-word-count').text(wordCount);
+
+ // Update sidebar stats (use attribute selectors to update both desktop and mobile sidebars)
+ $('[id="sidebar-word-count"]').text(wordCount.toLocaleString());
+ $('[id="sidebar-char-count"]').text(charCount.toLocaleString());
+ $('[id="sidebar-reading-time"]').text(readingTime);
+
+ // Restore "fresh" state on word count circle - change back to teal gradient
+ $('[id="word-count-circle"]')
+ .removeClass('from-gray-400 to-gray-500')
+ .addClass('from-teal-400 to-teal-600');
+ };
+
+ // Initial word count
+ updateWordCount();
+
+ // Debounced word count to prevent performance issues on large documents
+ let wordCountTimeout = null;
+ const debouncedWordCount = function() {
+ clearTimeout(wordCountTimeout);
+ wordCountTimeout = setTimeout(updateWordCount, 500); // Max 2x/sec instead of 60x/sec
+ };
+
+ // Trigger autosave and update word count on content changes
+ window.editor.subscribe('editableInput', function() {
+ // Queue an autosave
+ queueAutosave();
+ // Show "stale" state on word count circle - change to gray gradient
+ $('[id="word-count-circle"]')
+ .removeClass('from-teal-400 to-teal-600')
+ .addClass('from-gray-400 to-gray-500');
+ // Update word count (debounced for performance on large docs)
+ debouncedWordCount();
+ });
+
+ // Trigger autosave on title changes
+ $('#document_title').on('change', function() {
+ queueAutosave();
+ });
+
+ $('#document_title').on('keyup', function() {
+ if ($(this).val().length > 0) {
+ queueAutosave();
+ }
+ });
+
+ // Prevent Enter from submitting the form, focus editor instead
+ $('#document_title').on('keydown', function(e) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ $('#editor').focus();
+ }
+ });
+
+ // Manual save on click - immediate save, not queued
+ $('.js-autosave-status').on('click', performSave);
+
+ // Set up beforeunload handler to warn about unsaved changes
+ window.addEventListener('beforeunload', function(e) {
+ // Only show warning if we have unsaved changes
+ if (has_unsaved_changes) {
+ // Modern browsers
+ e.preventDefault();
+ e.returnValue = ''; // Chrome requires this
+
+ // Legacy browsers (return value is ignored in modern browsers)
+ return 'You have unsaved changes. Are you sure you want to leave?';
+ }
+ });
+
+ // Alternative legacy approach for older browsers
+ window.onbeforeunload = function() {
+ if (has_unsaved_changes) {
+ return 'You have unsaved changes. Are you sure you want to leave?';
+ }
+ };
+
+ // Allow entering `tab` into the editor
+ $(document).delegate('#editor', 'keydown', function(e) {
+ const keyCode = e.keyCode || e.which;
+ if (keyCode === 9) {
+ e.preventDefault();
+ }
+ });
+
+ // Keyboard shortcuts
+ $(document).on('keydown', function(e) {
+ // Save (Ctrl+S)
+ if (e.ctrlKey && e.key === 's') {
+ e.preventDefault();
+ performSave();
+ }
+ });
+
+ // Handle Save & Close button click for all metadata modals
+ $('.save-metadata-button').on('click', function() {
+ // Get the form closest to this button
+ const form = $(this).closest('form.metadata-form');
+
+ // Show saving state
+ const button = $(this);
+ const originalText = button.text();
+ button.html('hourglass_top Saving...');
+ button.prop('disabled', true);
+
+ // Submit the form via AJAX
+ $.ajax({
+ type: 'PATCH',
+ url: form.attr('action'),
+ data: form.serialize(),
+ success: function() {
+ // Show success state
+ button.html('check Saved!');
+
+ // Close the modal after a short delay
+ setTimeout(function() {
+ // Reset button
+ button.html(originalText);
+ button.prop('disabled', false);
+
+ // The modal should already be closed by the @click="open = false" on the button
+ }, 1000);
+ },
+ error: function() {
+ // Show error state
+ button.html('error Error!');
+
+ // Reset button after delay
+ setTimeout(function() {
+ button.html(originalText);
+ button.prop('disabled', false);
+ }, 2000);
+ }
+ });
+ });
+});
diff --git a/app/assets/javascripts/enhanced_autosave.js b/app/assets/javascripts/enhanced_autosave.js
new file mode 100644
index 000000000..5d4102368
--- /dev/null
+++ b/app/assets/javascripts/enhanced_autosave.js
@@ -0,0 +1,243 @@
+/**
+ * Unified Autosave System
+ *
+ * This is the single autosave system for all field types. It handles:
+ * - Text inputs/textareas: 300ms input debouncing, save on blur
+ * - Selects/checkboxes/radios/hidden fields: Immediate save on change
+ *
+ * How to use: Add the class 'js-autosave' to any input element.
+ * Optionally add a status indicator element nearby:
+ *
+ Start building your timeline by adding your first event. Track important moments, plot points, and key developments in chronological order. +
+ + `; + + // Add event listener to the new button + const newFirstEventBtn = emptyState.querySelector('#js-create-first-event'); + newFirstEventBtn.addEventListener('click', function() { + const timelineId = document.querySelector('.timeline-events-container').dataset.timelineId; + createTimelineEvent(timelineId); + emptyState.remove(); // Remove empty state when creating + }); + + eventsContainer.appendChild(emptyState); + } }); diff --git a/app/assets/javascripts/timeline_editor_component.js b/app/assets/javascripts/timeline_editor_component.js new file mode 100644 index 000000000..1ba53e823 --- /dev/null +++ b/app/assets/javascripts/timeline_editor_component.js @@ -0,0 +1,905 @@ +// Timeline Editor Alpine.js Component +// Extracted from timelines/edit.html.erb for maintainability +// +// Usage: Add a JSON data block in the view: +// + +// Event types configuration - loaded from server via JSON block +// This consolidates the previously duplicated icon/name maps +let EVENT_TYPES = {}; + +function timelineEditor() { + // Load initialization data from JSON block + const initDataElement = document.getElementById('timeline-editor-init-data'); + const initData = initDataElement ? JSON.parse(initDataElement.textContent) : {}; + + // Initialize EVENT_TYPES from server data + EVENT_TYPES = initData.event_types || { + 'general': { icon: 'radio_button_checked', name: 'General' }, + 'setup': { icon: 'foundation', name: 'Setup' }, + 'exposition': { icon: 'info', name: 'Exposition' }, + 'inciting_incident': { icon: 'flash_on', name: 'Inciting Incident' }, + 'complication': { icon: 'warning', name: 'Complication' }, + 'obstacle': { icon: 'block', name: 'Obstacle' }, + 'conflict': { icon: 'gavel', name: 'Conflict' }, + 'progress': { icon: 'trending_up', name: 'Progress' }, + 'revelation': { icon: 'visibility', name: 'Revelation' }, + 'transformation': { icon: 'autorenew', name: 'Transformation' }, + 'climax': { icon: 'whatshot', name: 'Climax' }, + 'resolution': { icon: 'check_circle', name: 'Resolution' }, + 'aftermath': { icon: 'restore', name: 'Aftermath' } + }; + + return { + // Modal and panel states + showMetaPanel: false, + showLinkModal: false, + showShareModal: false, + showFilters: false, + showMobileFilters: false, + linkingEventId: null, + draggedEvent: null, + + // Search and filter state + searchQuery: '', + linkModalSearchQuery: '', + selectedFilter: 'all', + collapsedSections: {}, + contentSummaryCollapsed: {}, + + // Event selection state + selectedEvents: [], + selectedEventId: null, + selectedEventData: null, + + // Auto-save status + autoSaveStatus: 'saved', + + // Event sections and filters + eventSections: {}, + eventTypeFilters: [], + importanceFilters: [], + statusFilters: [], + + // Tag management + eventTags: {}, + loadingTags: new Set(), + timelineTagSuggestions: [], + + // Tag filtering state + showTagFilters: false, + showFilterSection: false, + selectedTagFilters: [], + tagFilterMode: 'filter', + + // Reactive properties for real-time inspector panel updates + liveTitle: '', + liveTimeLabel: '', + liveEndTimeLabel: '', + liveDescription: '', + activeInputListeners: new Map(), + + // Timeline form data - loaded from JSON block + timelineName: initData.timeline?.name || '', + timelineSubtitle: initData.timeline?.subtitle || '', + + // Inline edit states + editingTitle: false, + editingSubtitle: false, + tempTitle: initData.timeline?.name || '', + tempSubtitle: initData.timeline?.subtitle || '', + + // Initialize method + init() { + // Initialize eventTags from server-rendered DOM data + this.initializeEventTagsFromDOM(); + + // Load timeline tag suggestions for autocomplete + this.loadTimelineTagSuggestions(); + + // Add fallback click handlers for server-rendered events + setTimeout(() => { + const serverEventCards = document.querySelectorAll('.timeline-event-card:not(.js-template-event-card)'); + + serverEventCards.forEach((card) => { + const eventContainer = card.closest('[data-event-id]'); + if (eventContainer) { + const eventId = parseInt(eventContainer.getAttribute('data-event-id')); + + card.addEventListener('click', (e) => { + if (e.alpineProcessed) return; + + const titleEl = card.querySelector('input[name*="[title]"]'); + const timeEl = card.querySelector('input[name*="[time_label]"]'); + const descEl = card.querySelector('textarea[name*="[description]"]'); + + const eventData = { + id: eventId, + title: titleEl ? titleEl.value : 'Untitled Event', + time_label: timeEl ? timeEl.value : '', + description: descEl ? descEl.value : '', + event_type: 'general', + importance_level: 'minor', + status: 'completed', + tags: [] + }; + + this.selectEvent(eventId, eventData); + }); + } + }); + }, 500); + }, + + // Initialize eventTags object from server-rendered DOM + initializeEventTagsFromDOM() { + const eventContainers = document.querySelectorAll('.timeline-event-container:not(.timeline-event-template)'); + + eventContainers.forEach(container => { + const eventId = container.getAttribute('data-event-id'); + if (!eventId) return; + + const tagContainer = container.querySelector(`#event-tags-${eventId}`); + if (!tagContainer) { + this.eventTags[eventId] = []; + return; + } + + const tagSpans = tagContainer.querySelectorAll('span'); + const tags = Array.from(tagSpans).map(span => span.textContent.trim()); + this.eventTags[eventId] = tags; + }); + }, + + // Load timeline tag suggestions for autocomplete + async loadTimelineTagSuggestions() { + const timelineId = initData.timeline?.id; + if (!timelineId) return; + + try { + const response = await fetch(`/plan/timelines/${timelineId}/tag_suggestions`); + const data = await response.json(); + this.timelineTagSuggestions = data.suggestions || []; + } catch (error) { + console.error('Failed to load timeline tag suggestions:', error); + this.timelineTagSuggestions = []; + } + }, + + // Filter helper methods + clearAllFilters() { + this.eventTypeFilters = []; + this.importanceFilters = []; + this.statusFilters = []; + }, + + getFilteredEventCount() { + const events = document.querySelectorAll('.timeline-event-container:not(.timeline-event-template)'); + let count = 0; + events.forEach(event => { + if (this.shouldShowEvent(event)) count++; + }); + return count; + }, + + shouldShowEvent(eventElement) { + const eventData = this.getEventDataFromElement(eventElement); + + // Search filter + if (this.searchQuery && !this.matchesSearch(eventData)) { + return false; + } + + // Tag filter - only apply in filter mode + if (this.tagFilterMode === 'filter' && this.selectedTagFilters.length > 0) { + const eventId = eventElement.getAttribute('data-event-id'); + const eventTags = this.getEventTags(eventId); + const hasSelectedTag = this.selectedTagFilters.some(selectedTag => + eventTags.includes(selectedTag) + ); + if (!hasSelectedTag) return false; + } + + // Type filter + if (this.eventTypeFilters.length > 0 && !this.eventTypeFilters.includes(eventData.type)) { + return false; + } + + // Importance filter + if (this.importanceFilters.length > 0 && !this.importanceFilters.includes(eventData.importance)) { + return false; + } + + // Status filter + if (this.statusFilters.length > 0 && !this.statusFilters.includes(eventData.status)) { + return false; + } + + return true; + }, + + getEventDataFromElement(eventElement) { + const title = eventElement.querySelector('input[name*="[title]"]')?.value || ''; + const description = eventElement.querySelector('textarea[name*="[description]"]')?.value || ''; + const type = eventElement.dataset.eventType || eventElement.getAttribute('data-event-type') || 'general'; + const importance = eventElement.dataset.importance || eventElement.getAttribute('data-importance') || 'minor'; + const status = eventElement.dataset.status || eventElement.getAttribute('data-status') || 'completed'; + + return { + title: title.toLowerCase(), + description: description.toLowerCase(), + type: type || 'general', + importance: importance || 'minor', + status: status || 'completed' + }; + }, + + matchesSearch(eventData) { + const query = this.searchQuery.toLowerCase(); + return eventData.title.includes(query) || eventData.description.includes(query); + }, + + // Apply real-time filtering to timeline events + applyEventFilters() { + const events = document.querySelectorAll('.timeline-event-container:not(.timeline-event-template)'); + let visibleCount = 0; + + events.forEach(eventElement => { + const shouldShow = this.shouldShowEvent(eventElement); + const eventId = eventElement.getAttribute('data-event-id'); + + if (shouldShow) { + eventElement.style.display = 'block'; + eventElement.style.opacity = '1'; + eventElement.style.transform = 'translateY(0)'; + visibleCount++; + } else { + eventElement.style.opacity = '0'; + eventElement.style.transform = 'translateY(-10px)'; + setTimeout(() => { + if (!this.shouldShowEvent(eventElement)) { + eventElement.style.display = 'none'; + } + }, 200); + } + + // Update tag display for highlight mode + if (eventId && shouldShow && this.tagFilterMode === 'highlight' && this.selectedTagFilters.length > 0) { + this.updateMainPanelTags(eventId); + } + }); + + this.updateEmptyState(visibleCount); + return visibleCount; + }, + + updateEmptyState(visibleCount) { + const eventsContainer = document.querySelector('.timeline-events-container'); + let emptyState = eventsContainer.querySelector('.search-empty-state'); + + if (visibleCount === 0 && this.searchQuery.length > 0) { + if (!emptyState) { + emptyState = document.createElement('div'); + emptyState.className = 'search-empty-state text-center py-16'; + emptyState.innerHTML = ` ++ Try adjusting your search terms or clear the search to see all events. +
+ + `; + eventsContainer.appendChild(emptyState); + } + emptyState.style.display = 'block'; + } else { + if (emptyState) { + emptyState.style.display = 'none'; + } + } + }, + + // Link modal methods + openLinkModal(eventId) { + this.linkingEventId = eventId; + this.showLinkModal = true; + this.linkModalSearchQuery = ''; + this.selectedFilter = 'all'; + this.showMobileFilters = false; + setTimeout(() => { + const searchInput = document.querySelector('.link-modal-search'); + if (searchInput) searchInput.focus(); + }, 100); + }, + + closeLinkModal() { + this.showLinkModal = false; + this.linkingEventId = null; + this.linkModalSearchQuery = ''; + this.selectedFilter = 'all'; + this.showMobileFilters = false; + this.collapsedSections = {}; + }, + + toggleSection(sectionKey) { + this.collapsedSections[sectionKey] = !this.collapsedSections[sectionKey]; + }, + + isSectionCollapsed(sectionKey) { + return this.collapsedSections[sectionKey] || false; + }, + + // Content summary section management + toggleContentSummarySection(contentType) { + this.contentSummaryCollapsed[contentType] = !this.contentSummaryCollapsed[contentType]; + }, + + isContentSummaryCollapsed(contentType) { + return this.contentSummaryCollapsed[contentType] ?? true; + }, + + linkableContentMatches(contentName, contentType) { + // First check filter selection + if (this.selectedFilter && this.selectedFilter !== 'all') { + if (this.selectedFilter === 'timeline') { + if (contentName !== 'timeline' && contentType !== 'timeline') { + return false; + } + } else { + if (this.selectedFilter !== contentType.toLowerCase()) { + return false; + } + } + } + + // Then apply search query if present + if (this.linkModalSearchQuery) { + const query = this.linkModalSearchQuery.toLowerCase(); + return contentName.toLowerCase().includes(query); + } + + return true; + }, + + linkFirstResult() { + const visibleButtons = document.querySelectorAll('.js-link-entity-selection'); + let firstVisibleButton = null; + + for (let button of visibleButtons) { + if (button.offsetParent !== null && !button.style.display.includes('none')) { + firstVisibleButton = button; + break; + } + } + + if (firstVisibleButton) { + firstVisibleButton.click(); + } + }, + + // Event section management + toggleEventSection(eventId, section) { + const key = `${eventId}_${section}`; + this.eventSections[key] = !this.eventSections[key]; + }, + + isEventSectionOpen(eventId, section) { + const key = `${eventId}_${section}`; + return this.eventSections[key] || false; + }, + + // Event selection for inspector panel + selectEvent(eventId, eventData) { + this.selectedEventId = eventId; + this.selectedEventData = eventData; + + this.setupLiveFormListeners(eventId); + // Use $nextTick to ensure DOM is rendered after panel state change + this.$nextTick(() => { + this.updateSidebarLinkedContent(eventId); + }); + + if (eventData && eventData.tags) { + this.eventTags[eventId] = eventData.tags; + if (eventData.tags.length > 0) { + this.updateMainPanelTags(eventId); + } + } else { + this.getEventTags(eventId); + } + + const inspectorContent = document.querySelector('.w-96 .overflow-y-auto'); + if (inspectorContent) { + inspectorContent.scrollTop = 0; + } + }, + + clearEventSelection() { + this.selectedEventId = null; + this.selectedEventData = null; + this.clearInputListeners(); + }, + + // Set up real-time input listeners for inspector panel updates + setupLiveFormListeners(eventId) { + this.clearInputListeners(); + + const eventContainer = document.querySelector(`[data-event-id="${eventId}"]`); + if (!eventContainer) return; + + const titleInput = eventContainer.querySelector('input[name*="[title]"]'); + const timeLabelInput = eventContainer.querySelector('input[name*="[time_label]"]'); + const endTimeLabelInput = eventContainer.querySelector('input[name*="[end_time_label]"]'); + const descriptionTextarea = eventContainer.querySelector('textarea[name*="[description]"]'); + + this.liveTitle = titleInput?.value || 'Untitled Event'; + this.liveTimeLabel = timeLabelInput?.value || ''; + this.liveEndTimeLabel = endTimeLabelInput?.value || ''; + this.liveDescription = descriptionTextarea?.value || ''; + + const listeners = []; + + if (titleInput) { + const titleListener = (e) => { this.liveTitle = e.target.value || 'Untitled Event'; }; + titleInput.addEventListener('input', titleListener); + listeners.push({ element: titleInput, type: 'input', listener: titleListener }); + } + + if (timeLabelInput) { + const timeLabelListener = (e) => { this.liveTimeLabel = e.target.value || ''; }; + timeLabelInput.addEventListener('input', timeLabelListener); + listeners.push({ element: timeLabelInput, type: 'input', listener: timeLabelListener }); + } + + if (endTimeLabelInput) { + const endTimeLabelListener = (e) => { this.liveEndTimeLabel = e.target.value || ''; }; + endTimeLabelInput.addEventListener('input', endTimeLabelListener); + listeners.push({ element: endTimeLabelInput, type: 'input', listener: endTimeLabelListener }); + } + + if (descriptionTextarea) { + const descriptionListener = (e) => { this.liveDescription = e.target.value || ''; }; + descriptionTextarea.addEventListener('input', descriptionListener); + listeners.push({ element: descriptionTextarea, type: 'input', listener: descriptionListener }); + } + + this.activeInputListeners.set(eventId, listeners); + }, + + clearInputListeners() { + this.activeInputListeners.forEach((listeners, eventId) => { + listeners.forEach(({ element, type, listener }) => { + element.removeEventListener(type, listener); + }); + }); + + this.activeInputListeners.clear(); + this.liveTitle = ''; + this.liveTimeLabel = ''; + this.liveEndTimeLabel = ''; + this.liveDescription = ''; + }, + + // Update sidebar linked content (both desktop and mobile containers) + updateSidebarLinkedContent(eventId) { + // Target both desktop and mobile containers + const containers = [ + document.getElementById('sidebar-linked-content'), + document.getElementById('sidebar-linked-content-mobile') + ].filter(c => c !== null); + + if (containers.length === 0) return; + + const emptyStateHTML = ` +"===c)return b.execCommand("outdent",!1,c);if((h.isFF||h.isEdge)&&"p"===c)return d=Array.prototype.slice.call(e.childNodes),d.some(function(a){return!h.isBlockContainer(a)})&&b.execCommand("formatBlock",!1,c),b.execCommand("outdent",!1,c)}return b.execCommand("formatBlock",!1,c)},setTargetBlank:function(a,b){var c,d=b||!1;if("a"===a.nodeName.toLowerCase())a.target="_blank",a.rel="noopener noreferrer";else for(a=a.getElementsByTagName("a"),c=0;cA",contentFA:''},superscript:{name:"superscript",action:"superscript",aria:"superscript",tagNames:["sup"],contentDefault:"x1",contentFA:''},subscript:{name:"subscript",action:"subscript",aria:"subscript",tagNames:["sub"],contentDefault:"x1",contentFA:''},image:{name:"image",action:"image",aria:"image",tagNames:["img"],contentDefault:"image",contentFA:''},html:{name:"html",action:"html",aria:"evaluate html",tagNames:["iframe","object"],contentDefault:"html",contentFA:''},orderedlist:{name:"orderedlist",action:"insertorderedlist",aria:"ordered list",tagNames:["ol"],useQueryState:!0,contentDefault:"1.",contentFA:''},unorderedlist:{name:"unorderedlist",action:"insertunorderedlist",aria:"unordered list",tagNames:["ul"],useQueryState:!0,contentDefault:"•",contentFA:''},indent:{name:"indent",action:"indent",aria:"indent",tagNames:[],contentDefault:"→",contentFA:''},outdent:{name:"outdent",action:"outdent",aria:"outdent",tagNames:[],contentDefault:"←",contentFA:''},justifyCenter:{name:"justifyCenter",action:"justifyCenter",aria:"center justify",tagNames:[],style:{prop:"text-align",value:"center"},contentDefault:"C",contentFA:''},justifyFull:{name:"justifyFull",action:"justifyFull",aria:"full justify",tagNames:[],style:{prop:"text-align",value:"justify"},contentDefault:"J",contentFA:''},justifyLeft:{name:"justifyLeft",action:"justifyLeft",aria:"left justify",tagNames:[],style:{prop:"text-align",value:"left"},contentDefault:"L",contentFA:''},justifyRight:{name:"justifyRight",action:"justifyRight",aria:"right justify",tagNames:[],style:{prop:"text-align",value:"right"},contentDefault:"R",contentFA:''},removeFormat:{name:"removeFormat",aria:"remove formatting",action:"removeFormat",contentDefault:"X",contentFA:''},quote:{name:"quote",action:"append-blockquote",aria:"blockquote",tagNames:["blockquote"],contentDefault:"“",contentFA:''},pre:{name:"pre",action:"append-pre",aria:"preformatted text",tagNames:["pre"],contentDefault:"0101",contentFA:''},h1:{name:"h1",action:"append-h1",aria:"header type one",tagNames:["h1"],contentDefault:"H1",contentFA:'1'},h2:{name:"h2",action:"append-h2",aria:"header type two",tagNames:["h2"],contentDefault:"H2",contentFA:'2'},h3:{name:"h3",action:"append-h3",aria:"header type three",tagNames:["h3"],contentDefault:"H3",contentFA:'3'},h4:{name:"h4",action:"append-h4",aria:"header type four",tagNames:["h4"],contentDefault:"H4",contentFA:'4'},h5:{name:"h5",action:"append-h5",aria:"header type five",tagNames:["h5"],contentDefault:"H5",contentFA:'5'},h6:{name:"h6",action:"append-h6",aria:"header type six",tagNames:["h6"],contentDefault:"H6",contentFA:'6'}}}(),function(){var b=a.extensions.button.extend({init:function(){a.extensions.button.prototype.init.apply(this,arguments)},formSaveLabel:"✓",formCloseLabel:"×",activeClass:"medium-editor-toolbar-form-active",hasForm:!0,getForm:function(){},isDisplayed:function(){return this.hasForm?this.getForm().classList.contains(this.activeClass):!1},showForm:function(){this.hasForm&&this.getForm().classList.add(this.activeClass)},hideForm:function(){this.hasForm&&this.getForm().classList.remove(this.activeClass)},showToolbarDefaultActions:function(){var a=this.base.getExtensionByName("toolbar");a&&a.showToolbarDefaultActions()},hideToolbarDefaultActions:function(){var a=this.base.getExtensionByName("toolbar");a&&a.hideToolbarDefaultActions()},setToolbarPosition:function(){var a=this.base.getExtensionByName("toolbar");a&&a.setToolbarPosition()}});a.extensions.form=b}(),function(){var b=a.extensions.form.extend({customClassOption:null,customClassOptionText:"Button",linkValidation:!1,placeholderText:"Paste or type a link",targetCheckbox:!1,targetCheckboxText:"Open in new window",name:"anchor",action:"createLink",aria:"link",tagNames:["a"],contentDefault:"#",contentFA:'',init:function(){a.extensions.form.prototype.init.apply(this,arguments),this.subscribe("editableKeydown",this.handleKeydown.bind(this))},handleClick:function(b){b.preventDefault(),b.stopPropagation();var c=a.selection.getSelectionRange(this.document);return"a"===c.startContainer.nodeName.toLowerCase()||"a"===c.endContainer.nodeName.toLowerCase()||a.util.getClosestTag(a.selection.getSelectedParentElement(c),"a")?this.execAction("unlink"):(this.isDisplayed()||this.showForm(),!1)},handleKeydown:function(b){a.util.isKey(b,a.util.keyCode.K)&&a.util.isMetaCtrlKey(b)&&!b.shiftKey&&this.handleClick(b)},getForm:function(){return this.form||(this.form=this.createForm()),this.form},getTemplate:function(){var a=[''];return a.push('',"fontawesome"===this.getEditorOption("buttonLabels")?'':this.formSaveLabel,""),a.push('',"fontawesome"===this.getEditorOption("buttonLabels")?'':this.formCloseLabel,""),this.targetCheckbox&&a.push('"),this.customClassOption&&a.push('"),a.join("")},isDisplayed:function(){return a.extensions.form.prototype.isDisplayed.apply(this)},hideForm:function(){a.extensions.form.prototype.hideForm.apply(this),this.getInput().value=""},showForm:function(b){var c=this.getInput(),d=this.getAnchorTargetCheckbox(),e=this.getAnchorButtonCheckbox();if(b=b||{value:""},"string"==typeof b&&(b={value:b}),this.base.saveSelection(),this.hideToolbarDefaultActions(),a.extensions.form.prototype.showForm.apply(this),this.setToolbarPosition(),c.value=b.value,c.focus(),d&&(d.checked="_blank"===b.target),e){var f=b.buttonClass?b.buttonClass.split(" "):[];e.checked=-1!==f.indexOf(this.customClassOption)}},destroy:function(){return this.form?(this.form.parentNode&&this.form.parentNode.removeChild(this.form),void delete this.form):!1},getFormOpts:function(){var a=this.getAnchorTargetCheckbox(),b=this.getAnchorButtonCheckbox(),c={value:this.getInput().value.trim()};return this.linkValidation&&(c.value=this.checkLinkFormat(c.value)),c.target="_self",a&&a.checked&&(c.target="_blank"),b&&b.checked&&(c.buttonClass=this.customClassOption),c},doFormSave:function(){var a=this.getFormOpts();this.completeFormSave(a)},completeFormSave:function(a){this.base.restoreSelection(),this.execAction(this.action,a),this.base.checkSelection()},ensureEncodedUri:function(a){return a===decodeURI(a)?encodeURI(a):a},ensureEncodedUriComponent:function(a){return a===decodeURIComponent(a)?encodeURIComponent(a):a},ensureEncodedParam:function(a){var b=a.split("="),c=b[0],d=b[1];return c+(void 0===d?"":"="+this.ensureEncodedUriComponent(d))},ensureEncodedQuery:function(a){return a.split("&").map(this.ensureEncodedParam.bind(this)).join("&")},checkLinkFormat:function(a){var b=/^([a-z]+:)?\/\/|^(mailto|tel|maps):|^\#/i,c=b.test(a),d="",e=/^\+?\s?\(?(?:\d\s?\-?\)?){3,20}$/,f=a.match(/^(.*?)(?:\?(.*?))?(?:#(.*))?$/),g=f[1],h=f[2],i=f[3];if(e.test(a))return"tel:"+a;if(!c){var j=g.split("/")[0];(j.match(/.+(\.|:).+/)||"localhost"===j)&&(d="http://")}return d+this.ensureEncodedUri(g)+(void 0===h?"":"?"+this.ensureEncodedQuery(h))+(void 0===i?"":"#"+i)},doFormCancel:function(){this.base.restoreSelection(),this.base.checkSelection()},attachFormEvents:function(a){var b=a.querySelector(".medium-editor-toolbar-close"),c=a.querySelector(".medium-editor-toolbar-save"),d=a.querySelector(".medium-editor-toolbar-input");this.on(a,"click",this.handleFormClick.bind(this)),this.on(d,"keyup",this.handleTextboxKeyup.bind(this)),this.on(b,"click",this.handleCloseClick.bind(this)),this.on(c,"click",this.handleSaveClick.bind(this),!0)},createForm:function(){var a=this.document,b=a.createElement("div");return b.className="medium-editor-toolbar-form",b.id="medium-editor-toolbar-form-anchor-"+this.getEditorId(),b.innerHTML=this.getTemplate(),this.attachFormEvents(b),b},getInput:function(){return this.getForm().querySelector("input.medium-editor-toolbar-input")},getAnchorTargetCheckbox:function(){return this.getForm().querySelector(".medium-editor-toolbar-anchor-target")},getAnchorButtonCheckbox:function(){return this.getForm().querySelector(".medium-editor-toolbar-anchor-button")},handleTextboxKeyup:function(b){return b.keyCode===a.util.keyCode.ENTER?(b.preventDefault(),void this.doFormSave()):void(b.keyCode===a.util.keyCode.ESCAPE&&(b.preventDefault(),this.doFormCancel()))},handleFormClick:function(a){a.stopPropagation()},handleSaveClick:function(a){a.preventDefault(),this.doFormSave()},handleCloseClick:function(a){a.preventDefault(),this.doFormCancel()}});a.extensions.anchor=b}(),function(){var b=a.Extension.extend({name:"anchor-preview",hideDelay:500,previewValueSelector:"a",showWhenToolbarIsVisible:!1,showOnEmptyLinks:!0,init:function(){this.anchorPreview=this.createPreview(),this.getEditorOption("elementsContainer").appendChild(this.anchorPreview),this.attachToEditables()},getInteractionElements:function(){return this.getPreviewElement()},getPreviewElement:function(){return this.anchorPreview},createPreview:function(){var a=this.document.createElement("div");return a.id="medium-editor-anchor-preview-"+this.getEditorId(),a.className="medium-editor-anchor-preview",a.innerHTML=this.getTemplate(),this.on(a,"click",this.handleClick.bind(this)),a},getTemplate:function(){return''},destroy:function(){this.anchorPreview&&(this.anchorPreview.parentNode&&this.anchorPreview.parentNode.removeChild(this.anchorPreview),delete this.anchorPreview)},hidePreview:function(){this.anchorPreview&&this.anchorPreview.classList.remove("medium-editor-anchor-preview-active"),this.activeAnchor=null},showPreview:function(a){return this.anchorPreview.classList.contains("medium-editor-anchor-preview-active")||a.getAttribute("data-disable-preview")?!0:(this.previewValueSelector&&(this.anchorPreview.querySelector(this.previewValueSelector).textContent=a.attributes.href.value,this.anchorPreview.querySelector(this.previewValueSelector).href=a.attributes.href.value),this.anchorPreview.classList.add("medium-toolbar-arrow-over"),this.anchorPreview.classList.remove("medium-toolbar-arrow-under"),this.anchorPreview.classList.contains("medium-editor-anchor-preview-active")||this.anchorPreview.classList.add("medium-editor-anchor-preview-active"),this.activeAnchor=a,this.positionPreview(),this.attachPreviewHandlers(),this)},positionPreview:function(a){a=a||this.activeAnchor;var b,c,d,e,f,g=this.window.innerWidth,h=this.anchorPreview.offsetHeight,i=a.getBoundingClientRect(),j=this.diffLeft,k=this.diffTop,l=this.getEditorOption("elementsContainer"),m=["absolute","fixed"].indexOf(window.getComputedStyle(l).getPropertyValue("position"))>-1,n={};b=this.anchorPreview.offsetWidth/2;var o=this.base.getExtensionByName("toolbar");o&&(j=o.diffLeft,k=o.diffTop),c=j-b,m?(e=l.getBoundingClientRect(),["top","left"].forEach(function(a){n[a]=i[a]-e[a]}),n.width=i.width,n.height=i.height,i=n,g=e.width,f=l.scrollTop):f=this.window.pageYOffset,d=i.left+i.width/2,f+=h+i.top+i.height-k-this.anchorPreview.offsetHeight,this.anchorPreview.style.top=Math.round(f)+"px",this.anchorPreview.style.right="initial",b>d?(this.anchorPreview.style.left=c+b+"px",this.anchorPreview.style.right="initial"):b>g-d?(this.anchorPreview.style.left="auto",this.anchorPreview.style.right=0):(this.anchorPreview.style.left=c+d+"px",this.anchorPreview.style.right="initial")},attachToEditables:function(){this.subscribe("editableMouseover",this.handleEditableMouseover.bind(this)),this.subscribe("positionedToolbar",this.handlePositionedToolbar.bind(this))},handlePositionedToolbar:function(){this.showWhenToolbarIsVisible||this.hidePreview()},handleClick:function(a){var b=this.base.getExtensionByName("anchor"),c=this.activeAnchor;b&&c&&(a.preventDefault(),this.base.selectElement(this.activeAnchor),this.base.delay(function(){if(c){var a={value:c.attributes.href.value,target:c.getAttribute("target"),buttonClass:c.getAttribute("class")};b.showForm(a),c=null}}.bind(this))),this.hidePreview()},handleAnchorMouseout:function(){this.anchorToPreview=null,this.off(this.activeAnchor,"mouseout",this.instanceHandleAnchorMouseout),this.instanceHandleAnchorMouseout=null},handleEditableMouseover:function(b){var c=a.util.getClosestTag(b.target,"a");if(!1!==c){if(!this.showOnEmptyLinks&&(!/href=["']\S+["']/.test(c.outerHTML)||/href=["']#\S+["']/.test(c.outerHTML)))return!0;var d=this.base.getExtensionByName("toolbar");if(!this.showWhenToolbarIsVisible&&d&&d.isDisplayed&&d.isDisplayed())return!0;this.activeAnchor&&this.activeAnchor!==c&&this.detachPreviewHandlers(),this.anchorToPreview=c,this.instanceHandleAnchorMouseout=this.handleAnchorMouseout.bind(this),this.on(this.anchorToPreview,"mouseout",this.instanceHandleAnchorMouseout),this.base.delay(function(){this.anchorToPreview&&this.showPreview(this.anchorToPreview)}.bind(this))}},handlePreviewMouseover:function(){this.lastOver=(new Date).getTime(),this.hovering=!0},handlePreviewMouseout:function(a){a.relatedTarget&&/anchor-preview/.test(a.relatedTarget.className)||(this.hovering=!1)},updatePreview:function(){if(this.hovering)return!0;var a=(new Date).getTime()-this.lastOver;a>this.hideDelay&&this.detachPreviewHandlers()},detachPreviewHandlers:function(){clearInterval(this.intervalTimer),this.instanceHandlePreviewMouseover&&(this.off(this.anchorPreview,"mouseover",this.instanceHandlePreviewMouseover),this.off(this.anchorPreview,"mouseout",this.instanceHandlePreviewMouseout),this.activeAnchor&&(this.off(this.activeAnchor,"mouseover",this.instanceHandlePreviewMouseover),this.off(this.activeAnchor,"mouseout",this.instanceHandlePreviewMouseout))),this.hidePreview(),this.hovering=this.instanceHandlePreviewMouseover=this.instanceHandlePreviewMouseout=null},attachPreviewHandlers:function(){this.lastOver=(new Date).getTime(),this.hovering=!0,this.instanceHandlePreviewMouseover=this.handlePreviewMouseover.bind(this),this.instanceHandlePreviewMouseout=this.handlePreviewMouseout.bind(this),this.intervalTimer=setInterval(this.updatePreview.bind(this),200),this.on(this.anchorPreview,"mouseover",this.instanceHandlePreviewMouseover),this.on(this.anchorPreview,"mouseout",this.instanceHandlePreviewMouseout),this.on(this.activeAnchor,"mouseover",this.instanceHandlePreviewMouseover),this.on(this.activeAnchor,"mouseout",this.instanceHandlePreviewMouseout)}});a.extensions.anchorPreview=b}(),function(){function b(b){return!a.util.getClosestTag(b,"a")}var c,d,e,f,g;c=[" "," ","\n","\r"," "," "," "," "," ","\u2028","\u2029"],d="com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw",e="(((?:(https?://|ftps?://|nntp://)|www\\d{0,3}[.]|[a-z0-9.\\-]+[.]("+d+")\\/)\\S+(?:[^\\s`!\\[\\]{};:'\".,?«»“”‘’])))|(([a-z0-9\\-]+\\.)?[a-z0-9\\-]+\\.("+d+"))",f=new RegExp("^("+d+")$","i"),g=new RegExp(e,"gi");var h=a.Extension.extend({init:function(){a.Extension.prototype.init.apply(this,arguments),this.disableEventHandling=!1,this.subscribe("editableKeypress",this.onKeypress.bind(this)),this.subscribe("editableBlur",this.onBlur.bind(this)),this.document.execCommand("AutoUrlDetect",!1,!1)},isLastInstance:function(){for(var a=0,b=0;b
$/i),""],[new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi),""],[new RegExp(/<\/b>(
]*>)?$/gi),""],[new RegExp(/\s+<\/span>/g)," "],[new RegExp(/
/g),"
"],[new RegExp(/]*(font-style:italic;font-weight:(bold|700)|font-weight:(bold|700);font-style:italic)[^>]*>/gi),''],[new RegExp(/]*font-style:italic[^>]*>/gi),''],[new RegExp(/]*font-weight:(bold|700)[^>]*>/gi),''],[new RegExp(/<(\/?)(i|b|a)>/gi),"<$1$2>"],[new RegExp(/<a(?:(?!href).)+href=(?:"|”|“|"|“|”)(((?!"|”|“|"|“|”).)*)(?:"|”|“|"|“|”)(?:(?!>).)*>/gi),''],[new RegExp(/<\/p>\n+/gi),"
/gi),""],[new RegExp(/(((?!/gi),"$1"]]}function c(a,b,c){var d=a.clipboardData||b.clipboardData||c.dataTransfer,e={};if(!d)return e;if(d.getData){var f=d.getData("Text");f&&f.length>0&&(e["text/plain"]=f)}if(d.types)for(var g=0;g
"+a.split("
").join("
")+"
",d=e.querySelectorAll("a,p,div,br"),c=0;c#{content}
#{content}
" + else + # Handle any other cases as context + formatted_html += "#{content}
" + end + else # version == :new + # Show added and unchanged content for new version + case marker + when '+' + # Added content - show in green with bold + formatted_html += "#{content}
" + when '-' + # Removed content - skip in new version + when ' ' + # Context/unchanged content - show normally + formatted_html += "#{content}
" + else + # Handle any other cases as context + formatted_html += "#{content}
" + end + end + end + + if formatted_html.empty? + # If no diff content, show the actual revision content without highlighting + content = version == :old ? previous_text : current_text + if content.blank? + "No content
" + else + # Show the plain text content without diff highlighting + paragraphs = content.split(/\n\n+/).map(&:strip).reject(&:empty?) + paragraphs.map { |para| "#{CGI.escapeHTML(para)}
" }.join + end + else + formatted_html + end + end + + def generate_first_revision_view(text) + word_count = text.split.size + preview = text[0..500] + preview += "..." if text.length > 500 + + <<~HTML +This is the initial version of your document.
+ +#{CGI.escapeHTML(preview)}
++ Future changes to your document will be tracked and displayed here. +
+Header image (optional)
-- -
- ); - })} - -No suggestions available at the moment.
'; + } + }) + .catch(error => { + console.error('Error loading category suggestions:', error); + resultContainer.innerHTML = 'Failed to load suggestions. Please try again.
'; + }); + + this.style.display = 'none'; + }); + }); + + // Field suggestions + document.querySelectorAll('.js-show-field-suggestions').forEach(button => { + button.addEventListener('click', function(e) { + e.preventDefault(); + const contentType = document.querySelector('.attributes-editor').dataset.contentType; + const categoryContainer = this.closest('li') || this.closest('.category-card'); + const categoryLabel = categoryContainer.querySelector('.category-label').textContent.trim(); + const resultContainer = this.closest('div').querySelector('.suggest-fields-container'); + + // Show loading animation in the suggestions container + showSuggestionsLoadingAnimation(resultContainer); + + fetch(`/plan/attribute_fields/suggest?content_type=${contentType}&category=${categoryLabel}`) + .then(response => response.json()) + .then(data => { + const existingFields = Array.from(categoryContainer.querySelectorAll('.field-label')).map(el => el.textContent.trim()); + const newFields = data.filter(f => !existingFields.includes(f)); + + if (newFields.length > 0) { + resultContainer.innerHTML = ''; + newFields.forEach(field => { + const chip = document.createElement('span'); + chip.className = 'field-suggestion-link px-3 py-1 bg-gray-100 text-sm text-gray-800 rounded-full hover:bg-gray-200 cursor-pointer'; + chip.textContent = field; + chip.addEventListener('click', function() { + categoryContainer.querySelector('.js-field-input').value = field; + }); + resultContainer.appendChild(chip); + }); + } else { + resultContainer.innerHTML = 'No suggestions available at the moment.
'; + } + }) + .catch(error => { + console.error('Error loading field suggestions:', error); + resultContainer.innerHTML = 'Failed to load suggestions. Please try again.
'; + }); + + this.style.display = 'none'; + }); + }); +}); + +// Initialize sortable functionality using jQuery UI +function initSortables() { + if (typeof $ === 'undefined' || !$.fn.sortable) { + console.error('jQuery UI Sortable not found'); + return; + } + + // Categories sorting + $('#categories-container').sortable({ + items: '.category-card', + handle: '.category-drag-handle', + placeholder: 'category-placeholder', + cursor: 'move', + opacity: 0.8, + tolerance: 'pointer', + update: function(event, ui) { + const categoryId = ui.item.attr('data-category-id'); + const newPosition = ui.item.index(); + + // AJAX request to update position using internal endpoint + $.ajax({ + url: '/internal/sort/categories', + type: 'PATCH', + contentType: 'application/json', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + data: JSON.stringify({ + content_id: categoryId, + intended_position: newPosition + }), + success: function(data) { + console.log('Category position updated successfully:', data); + if (data.message) { + showNotification(data.message, 'success'); + } + + // Update the position field in the category config form if it's open + updateCategoryConfigPosition(categoryId, data.category.position); + }, + error: function(xhr, status, error) { + console.error('Error updating category position:', error); + showErrorMessage('Failed to reorder categories. Please try again.'); + } + }); + } + }); + + // Fields sorting for each category + $('.fields-container').sortable({ + items: '.field-item', + handle: '.field-drag-handle', + placeholder: 'field-placeholder', + cursor: 'move', + opacity: 0.8, + tolerance: 'pointer', + connectWith: '.fields-container', + update: function(event, ui) { + const fieldId = ui.item.attr('data-field-id'); + const newPosition = ui.item.index(); + const categoryId = ui.item.closest('.fields-container').attr('data-category-id'); + + // AJAX request to update position using internal endpoint + $.ajax({ + url: '/internal/sort/fields', + type: 'PATCH', + contentType: 'application/json', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + data: JSON.stringify({ + content_id: fieldId, + intended_position: newPosition, + attribute_category_id: categoryId + }), + success: function(data) { + console.log('Field position updated successfully:', data); + if (data.message) { + showNotification(data.message, 'success'); + } + + // Update the position field in the field config form if it's open + updateFieldConfigPosition(fieldId, data.field.position); + }, + error: function(xhr, status, error) { + console.error('Error updating field position:', error); + showErrorMessage('Failed to reorder fields. Please try again.'); + } + }); + } + }); +} + +// Notification system +function showNotification(message, type = 'info') { + const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'; + const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'; + + const notification = $(` +Password strength
+ `; + + // Check if we're on sign-up page with two-column layout + const passwordParent = passwordField.parentNode; + const isSignUpPage = document.querySelector('form[action*="/users"]') && + document.querySelector('input[name="user[password_confirmation]"]'); + + if (isSignUpPage && passwordParent.classList.contains('flex-1')) { + // On sign-up page, append the strength meter after the flex container + const flexContainer = passwordParent.parentNode; + if (flexContainer && flexContainer.classList.contains('flex')) { + // Insert the strength meter after the flex container + flexContainer.insertAdjacentElement('afterend', strengthMeter); + // Make it span full width + strengthMeter.classList.add('w-full', 'px-4'); + } else { + passwordParent.appendChild(strengthMeter); + } + } else { + // On other pages (like password change), append to the password field's parent + passwordParent.appendChild(strengthMeter); + } + } + + passwordField.addEventListener('input', function() { + const password = this.value; + + if (password.length > 0) { + strengthMeter.classList.remove('invisible'); + strengthMeter.classList.add('visible'); + updatePasswordStrength(password, strengthMeter); + } else { + strengthMeter.classList.remove('visible'); + strengthMeter.classList.add('invisible'); + } + }); +} + +// Update password strength meter +function updatePasswordStrength(password, meterElement) { + // Calculate password strength (simplified version) + let strength = 0; + + if (password.length >= 8) strength++; + if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++; + if (password.match(/\d/)) strength++; + if (password.match(/[^a-zA-Z\d]/)) strength++; + + // Update strength meter + const strengthBars = meterElement.querySelectorAll('[data-strength]'); + const strengthText = meterElement.querySelector('.strength-text'); + + strengthBars.forEach(bar => { + const barStrength = parseInt(bar.getAttribute('data-strength')); + + if (barStrength <= strength) { + bar.classList.remove('bg-gray-200'); + + if (strength === 1) bar.classList.add('bg-red-500'); + else if (strength === 2) bar.classList.add('bg-orange-500'); + else if (strength === 3) bar.classList.add('bg-yellow-500'); + else bar.classList.add('bg-green-500'); + } else { + bar.className = 'h-1 w-1/4 rounded-full bg-gray-200'; + } + }); + + // Update text + if (strength === 0) strengthText.textContent = 'Password is too weak'; + else if (strength === 1) strengthText.textContent = 'Password is weak'; + else if (strength === 2) strengthText.textContent = 'Password is fair'; + else if (strength === 3) strengthText.textContent = 'Password is good'; + else strengthText.textContent = 'Password is strong'; + + // Update text color + strengthText.className = 'text-xs mt-1 '; + if (strength === 0 || strength === 1) strengthText.className += 'text-red-500'; + else if (strength === 2) strengthText.className += 'text-orange-500'; + else if (strength === 3) strengthText.className += 'text-yellow-600'; + else strengthText.className += 'text-green-600'; +} + +// Initialize toast notifications +function initializeToasts() { + window.showToast = function(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = 'fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg transform transition-all duration-500 translate-y-20 opacity-0 z-50'; + + // Set color based on type + if (type === 'success') { + toast.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + toast.classList.add('bg-red-500', 'text-white'); + } else if (type === 'info') { + toast.classList.add('bg-blue-500', 'text-white'); + } + + toast.textContent = message; + document.body.appendChild(toast); + + // Show toast + setTimeout(() => { + toast.classList.remove('translate-y-20', 'opacity-0'); + }, 100); + + // Hide toast after 3 seconds + setTimeout(() => { + toast.classList.add('translate-y-20', 'opacity-0'); + + // Remove from DOM after animation + setTimeout(() => { + document.body.removeChild(toast); + }, 500); + }, 3000); + }; + + // Add event listeners to settings forms only + // Only apply to forms on settings pages or forms with specific settings classes + const settingsForms = document.querySelectorAll( + '.settings-form, ' + + 'form[action*="/settings"], ' + + 'form[action*="/customization"], ' + + 'form[action*="/billing"], ' + + 'form[action*="/account"]' + ); + + settingsForms.forEach(form => { + form.addEventListener('submit', function() { + // Store a flag in localStorage to show toast after redirect + localStorage.setItem('showSettingsSavedToast', 'true'); + }); + }); + + // Check if we need to show a toast (after redirect) + if (localStorage.getItem('showSettingsSavedToast') === 'true') { + showToast('Settings saved successfully!', 'success'); + localStorage.removeItem('showSettingsSavedToast'); + } +} + +// Initialize mobile navigation +function initializeMobileNav() { + const mobileNavToggle = document.getElementById('mobile-nav-toggle'); + const settingsSidebar = document.querySelector('.settings-sidebar-mobile'); + + if (mobileNavToggle && settingsSidebar) { + mobileNavToggle.addEventListener('click', function() { + settingsSidebar.classList.toggle('translate-x-0'); + settingsSidebar.classList.toggle('-translate-x-full'); + }); + } +} + diff --git a/app/javascript/stylesheets/rails_admin.scss b/app/javascript/stylesheets/rails_admin.scss index 8ca6f865f..a73294ba7 100644 --- a/app/javascript/stylesheets/rails_admin.scss +++ b/app/javascript/stylesheets/rails_admin.scss @@ -1 +1,3 @@ +// Use @use for modern Sass, but we need to fallback to @import for rails_admin +// since it uses old syntax internally. We'll suppress its deprecation warnings. @import "rails_admin/src/rails_admin/styles/base"; diff --git a/app/javascript/utils/timezone.js b/app/javascript/utils/timezone.js new file mode 100644 index 000000000..39be7aa0b --- /dev/null +++ b/app/javascript/utils/timezone.js @@ -0,0 +1,108 @@ +// Shared timezone utility - single source of truth for Rails <-> IANA conversions +// Rails ActiveSupport::TimeZone uses names like "Pacific Time (US & Canada)" +// JavaScript Intl API uses IANA identifiers like "America/Los_Angeles" + +export const IANA_TO_RAILS = { + 'America/New_York': 'Eastern Time (US & Canada)', + 'America/Chicago': 'Central Time (US & Canada)', + 'America/Denver': 'Mountain Time (US & Canada)', + 'America/Los_Angeles': 'Pacific Time (US & Canada)', + 'America/Anchorage': 'Alaska', + 'Pacific/Honolulu': 'Hawaii', + 'America/Phoenix': 'Arizona', + 'America/Indiana/Indianapolis': 'Indiana (East)', + 'America/Puerto_Rico': 'Atlantic Time (Canada)', + 'Europe/London': 'London', + 'Europe/Paris': 'Paris', + 'Europe/Berlin': 'Berlin', + 'Europe/Amsterdam': 'Amsterdam', + 'Europe/Madrid': 'Madrid', + 'Europe/Rome': 'Rome', + 'Europe/Vienna': 'Vienna', + 'Europe/Brussels': 'Brussels', + 'Europe/Stockholm': 'Stockholm', + 'Europe/Helsinki': 'Helsinki', + 'Europe/Athens': 'Athens', + 'Europe/Moscow': 'Moscow', + 'Asia/Tokyo': 'Tokyo', + 'Asia/Seoul': 'Seoul', + 'Asia/Shanghai': 'Beijing', + 'Asia/Hong_Kong': 'Hong Kong', + 'Asia/Singapore': 'Singapore', + 'Asia/Kolkata': 'Kolkata', + 'Asia/Mumbai': 'Mumbai', + 'Asia/Bangkok': 'Bangkok', + 'Asia/Jakarta': 'Jakarta', + 'Asia/Dubai': 'Abu Dhabi', + 'Asia/Jerusalem': 'Jerusalem', + 'Australia/Sydney': 'Sydney', + 'Australia/Melbourne': 'Melbourne', + 'Australia/Brisbane': 'Brisbane', + 'Australia/Perth': 'Perth', + 'Australia/Adelaide': 'Adelaide', + 'Australia/Darwin': 'Darwin', + 'Australia/Hobart': 'Hobart', + 'Pacific/Auckland': 'Auckland', + 'Pacific/Fiji': 'Fiji', + 'America/Sao_Paulo': 'Brasilia', + 'America/Buenos_Aires': 'Buenos Aires', + 'America/Santiago': 'Santiago', + 'America/Lima': 'Lima', + 'America/Bogota': 'Bogota', + 'America/Mexico_City': 'Mexico City', + 'America/Toronto': 'Eastern Time (US & Canada)', + 'America/Vancouver': 'Pacific Time (US & Canada)', + 'Africa/Cairo': 'Cairo', + 'Africa/Johannesburg': 'Pretoria', + 'Africa/Lagos': 'West Central Africa', + 'UTC': 'UTC' +} + +// Build reverse mapping (Rails -> IANA) programmatically +export const RAILS_TO_IANA = Object.fromEntries( + Object.entries(IANA_TO_RAILS).map(([iana, rails]) => [rails, iana]) +) + +/** + * Convert Rails timezone name to IANA identifier for use with JavaScript Intl API + * @param {string} railsTimezone - Rails timezone name like "Pacific Time (US & Canada)" + * @returns {string} IANA identifier like "America/Los_Angeles", or original value if not found + */ +export function railsToIana(railsTimezone) { + return RAILS_TO_IANA[railsTimezone] || railsTimezone +} + +/** + * Convert IANA timezone identifier to Rails timezone name + * @param {string} ianaTimezone - IANA identifier like "America/Los_Angeles" + * @returns {string|null} Rails timezone name, or null if not mappable + */ +export function ianaToRails(ianaTimezone) { + // Direct lookup + if (IANA_TO_RAILS[ianaTimezone]) { + return IANA_TO_RAILS[ianaTimezone] + } + + // Fallback: Try to match by offset for common US timezones + // This handles less common city names in the same timezone (e.g., America/Detroit) + const parts = ianaTimezone.split('/') + if (parts.length >= 2 && parts[0] === 'America') { + const now = new Date() + const offset = -now.getTimezoneOffset() / 60 + + if (offset === -5 || offset === -4) return 'Eastern Time (US & Canada)' + if (offset === -6 || offset === -5) return 'Central Time (US & Canada)' + if (offset === -7 || offset === -6) return 'Mountain Time (US & Canada)' + if (offset === -8 || offset === -7) return 'Pacific Time (US & Canada)' + } + + return null +} + +/** + * Detect the browser's timezone + * @returns {string} IANA timezone identifier + */ +export function detectBrowserTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone +} diff --git a/app/jobs/cache_attribute_word_count_job.rb b/app/jobs/cache_attribute_word_count_job.rb index c76705a41..4d366fc39 100644 --- a/app/jobs/cache_attribute_word_count_job.rb +++ b/app/jobs/cache_attribute_word_count_job.rb @@ -3,7 +3,7 @@ class CacheAttributeWordCountJob < ApplicationJob def perform(*args) attribute_id = args.shift - attribute = Attribute.find_by(id: attribute_id) + attribute = Attribute.includes(:attribute_field).find_by(id: attribute_id) # If the attribute has been deleted since this job was enqueued, just bail if attribute.nil? @@ -11,28 +11,20 @@ def perform(*args) end # If we have a blank/null value, ezpz 0 words - if attribute.nil? || attribute.value.nil? || attribute.value.blank? + if attribute.value.nil? || attribute.value.blank? attribute.update_column(:word_count_cache, 0) return end - # If we actually have some content, use a smart WordCountAnalyzer instead of just splitting on spaces - word_count = WordCountAnalyzer::Counter.new( - ellipsis: 'no_special_treatment', - hyperlink: 'count_as_one', - contraction: 'count_as_one', - hyphenated_word: 'count_as_one', - date: 'no_special_treatment', - number: 'count', - numbered_list: 'ignore', - xhtml: 'remove', - forward_slash: 'count_as_multiple_except_dates', - backslash: 'count_as_one', - dotted_line: 'ignore', - dashed_line: 'ignore', - underscore: 'ignore', - stray_punctuation: 'ignore' - ).count(attribute.value) + # Skip word counting for non-prose field types (links store JSON, not text) + field_type = attribute.attribute_field&.field_type + if %w[link universe tags].include?(field_type) + attribute.update_column(:word_count_cache, 0) + return + end + + # Use centralized WordCountService for consistent counting across the app + word_count = WordCountService.count_with_fallback(attribute.value) attribute.update_column(:word_count_cache, word_count) end diff --git a/app/jobs/cache_book_word_count_job.rb b/app/jobs/cache_book_word_count_job.rb new file mode 100644 index 000000000..35b9b736c --- /dev/null +++ b/app/jobs/cache_book_word_count_job.rb @@ -0,0 +1,40 @@ +class CacheBookWordCountJob < ApplicationJob + queue_as :cache + + MAX_RETRY_ATTEMPTS = 3 + + def perform(book_id) + book = Book.find_by(id: book_id) + return if book.nil? + + # Count words in description and blurb + description_words = book.description.present? ? WordCountService.count_with_fallback(book.description) : 0 + blurb_words = book.blurb.present? ? WordCountService.count_with_fallback(book.blurb) : 0 + total_word_count = description_words + blurb_words + + # Cache the total word count on the model + book.update_column(:cached_word_count, total_word_count) + + # Determine the user's date based on their timezone at enqueue time (not job execution time) + user = book.user + enqueue_time = enqueued_at || Time.current + user_date = user&.time_zone.present? ? enqueue_time.in_time_zone(user.time_zone).to_date : enqueue_time.to_date + + # Create or update WordCountUpdate for today (in user's timezone) + retry_count = 0 + begin + update = book.word_count_updates.find_or_initialize_by(for_date: user_date) + update.word_count = total_word_count + update.user_id ||= book.user_id + update.save! + rescue ActiveRecord::RecordNotUnique + retry_count += 1 + if retry_count <= MAX_RETRY_ATTEMPTS + retry + else + Rails.logger.error("CacheBookWordCountJob: max retries exceeded for Book##{book_id} on #{user_date}") + raise # Let Sidekiq handle with its retry mechanism + end + end + end +end diff --git a/app/jobs/cache_sum_attribute_word_count_job.rb b/app/jobs/cache_sum_attribute_word_count_job.rb index 37970c290..0ad5d840e 100644 --- a/app/jobs/cache_sum_attribute_word_count_job.rb +++ b/app/jobs/cache_sum_attribute_word_count_job.rb @@ -1,6 +1,8 @@ class CacheSumAttributeWordCountJob < ApplicationJob queue_as :cache + MAX_RETRY_ATTEMPTS = 3 + def perform(*args) entity_type = args.shift entity_id = args.shift @@ -18,14 +20,31 @@ def save_daily_word_count_changes(entity) # Cache the total word count onto the model, too entity.update(cached_word_count: sum_attribute_word_count) - # Create or re-use an existing WordCountUpdate for today - update = entity.word_count_updates.find_or_initialize_by( - for_date: DateTime.current, - ) - update.word_count = sum_attribute_word_count - update.user_id ||= entity.user_id + # Determine the user's date based on their timezone at enqueue time (not job execution time) + user = entity.user + enqueue_time = enqueued_at || Time.current + user_date = user&.time_zone.present? ? enqueue_time.in_time_zone(user.time_zone).to_date : enqueue_time.to_date + + # Create or re-use an existing WordCountUpdate for today (in user's timezone) + retry_count = 0 + begin + update = entity.word_count_updates.find_or_initialize_by( + for_date: user_date, + ) + update.word_count = sum_attribute_word_count + update.user_id ||= entity.user_id - # Save! - update.save! + # Save! + update.save! + rescue ActiveRecord::RecordNotUnique + # Unique index exists and found a duplicate via race condition - retry to find the existing record + retry_count += 1 + if retry_count <= MAX_RETRY_ATTEMPTS + retry + else + Rails.logger.error("CacheSumAttributeWordCountJob: max retries exceeded for #{entity.class.name}##{entity.id} on #{user_date}") + raise # Let Sidekiq handle with its retry mechanism + end + end end end diff --git a/app/jobs/cache_timeline_event_word_count_job.rb b/app/jobs/cache_timeline_event_word_count_job.rb new file mode 100644 index 000000000..558cf70c0 --- /dev/null +++ b/app/jobs/cache_timeline_event_word_count_job.rb @@ -0,0 +1,43 @@ +class CacheTimelineEventWordCountJob < ApplicationJob + queue_as :cache + + MAX_RETRY_ATTEMPTS = 3 + + def perform(timeline_event_id) + timeline_event = TimelineEvent.find_by(id: timeline_event_id) + return if timeline_event.nil? + + # Count words in title and description + title_words = timeline_event.title.present? ? WordCountService.count_with_fallback(timeline_event.title) : 0 + description_words = timeline_event.description.present? ? WordCountService.count_with_fallback(timeline_event.description) : 0 + total_word_count = title_words + description_words + + # Cache the total word count on the model + timeline_event.update_column(:cached_word_count, total_word_count) + + # Get user via timeline association + user = timeline_event.timeline&.user + return if user.nil? + + # Determine the user's date based on their timezone at enqueue time (not job execution time) + enqueue_time = enqueued_at || Time.current + user_date = user.time_zone.present? ? enqueue_time.in_time_zone(user.time_zone).to_date : enqueue_time.to_date + + # Create or update WordCountUpdate for today (in user's timezone) + retry_count = 0 + begin + update = timeline_event.word_count_updates.find_or_initialize_by(for_date: user_date) + update.word_count = total_word_count + update.user_id ||= user.id + update.save! + rescue ActiveRecord::RecordNotUnique + retry_count += 1 + if retry_count <= MAX_RETRY_ATTEMPTS + retry + else + Rails.logger.error("CacheTimelineEventWordCountJob: max retries exceeded for TimelineEvent##{timeline_event_id} on #{user_date}") + raise # Let Sidekiq handle with its retry mechanism + end + end + end +end diff --git a/app/jobs/daily_word_goal_notification_job.rb b/app/jobs/daily_word_goal_notification_job.rb new file mode 100644 index 000000000..f23ed140a --- /dev/null +++ b/app/jobs/daily_word_goal_notification_job.rb @@ -0,0 +1,34 @@ +class DailyWordGoalNotificationJob < ApplicationJob + include ActionView::Helpers::NumberHelper + queue_as :notifications + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user + + today = user.current_date_in_time_zone + today_start = today.beginning_of_day + today_end = today.end_of_day + + # Already notified today? Check notifications table + already_notified = user.notifications.where( + reference_code: 'daily-goal-achieved' + ).where(happened_at: today_start..today_end).exists? + return if already_notified + + # Check if goal met + words_today = user.words_written_today + goal = user.daily_word_goal + return unless words_today >= goal + + # Send congratulatory notification + user.notifications.create!( + message_html: "Congratulations! You hit your daily word goal of #{number_with_delimiter(goal)} words!", + icon: 'emoji_events', + icon_color: 'yellow', + passthrough_link: '/writing-goals', + reference_code: 'daily-goal-achieved', + happened_at: Time.current + ) + end +end diff --git a/app/jobs/document_mention_job.rb b/app/jobs/document_mention_job.rb index 0be41122a..7b2888a7c 100644 --- a/app/jobs/document_mention_job.rb +++ b/app/jobs/document_mention_job.rb @@ -21,7 +21,8 @@ def perform(*args) # by the document owner, or else people could add arbitrary pages to quick reference # to view them. next unless Rails.application.config.content_types[:all].map(&:name).include?(entity_type) - related_page = entity_type.constantize.find(id) + related_page = entity_type.constantize.find_by(id: id) + next unless related_page.present? # todo we could also work off privacy here, so people could add public pages to quick ference # -- we'd have to delete those quick-references whenever a page went private though next unless related_page.user_id == document.user_id diff --git a/app/jobs/save_document_revision_job.rb b/app/jobs/save_document_revision_job.rb index 1513516cd..9bccee771 100644 --- a/app/jobs/save_document_revision_job.rb +++ b/app/jobs/save_document_revision_job.rb @@ -1,40 +1,65 @@ class SaveDocumentRevisionJob < ApplicationJob queue_as :low_priority + MAX_RETRY_ATTEMPTS = 3 + def perform(*args) document_id = args.shift document = Document.find_by(id: document_id) return unless document # Initialize variables; body is NOT loaded yet - new_word_count = 0 body_loaded = false body_text = nil - begin - # Try the accurate (but potentially memory-intensive) count first - # This accesses document.body internally - new_word_count = document.computed_word_count - rescue StandardError => e - # Log the error for visibility - Rails.logger.warn("SaveDocumentRevisionJob: Failed accurate word count for Document #{document_id}: #{e.message}. Falling back to basic count.") - - # Fallback: Load body ONLY if needed for fallback count - body_text = document.body || "" # Load body here - body_loaded = true - new_word_count = body_text.split.size + # Use client-provided word count if available (browser edit), otherwise calculate server-side (API/import) + if document.cached_word_count.present? && document.cached_word_count > 0 + # Client already provided the count via autosave - use it as-is + new_word_count = document.cached_word_count + else + # No client count (API/import edit) - calculate server-side + begin + # Try the accurate (but potentially memory-intensive) count first + # This accesses document.body internally + new_word_count = document.computed_word_count + rescue StandardError => e + # Log the error for visibility + Rails.logger.warn("SaveDocumentRevisionJob: Failed accurate word count for Document #{document_id}: #{e.message}. Falling back to basic count.") + + # Fallback: Load body ONLY if needed for fallback count + body_text = document.body || "" # Load body here + body_loaded = true + new_word_count = body_text.split.size + end + + # Only update cached_word_count if we calculated it server-side + document.update(cached_word_count: new_word_count) end - # Update cached word count for the document (always do this) - document.update(cached_word_count: new_word_count) + # Save a WordCountUpdate for this document for the day the edit was made + # Use enqueued_at (when the job was created) to handle Sidekiq delays correctly + user = document.user + enqueue_time = enqueued_at || Time.current + user_date = user&.time_zone.present? ? enqueue_time.in_time_zone(user.time_zone).to_date : enqueue_time.to_date - # Save a WordCountUpdate for this document for today (always do this) - update = document.word_count_updates.find_or_initialize_by( - for_date: DateTime.current, - ) - update.word_count = new_word_count - update.user_id ||= document.user_id - update.save! + retry_count = 0 + begin + update = document.word_count_updates.find_or_initialize_by( + for_date: user_date, + ) + update.word_count = new_word_count + update.user_id ||= document.user_id + update.save! + rescue ActiveRecord::RecordNotUnique + # Unique index exists and found a duplicate via race condition - retry to find the existing record + retry_count += 1 + if retry_count <= MAX_RETRY_ATTEMPTS + retry + else + Rails.logger.error("SaveDocumentRevisionJob: max retries exceeded for Document##{document_id} on #{user_date}") + raise # Let Sidekiq handle with its retry mechanism + end + end # Check if revision is needed BEFORE potentially loading body again latest_revision = document.document_revisions.order('created_at DESC').limit(1).first diff --git a/app/models/billing/promotion.rb b/app/models/billing/promotion.rb index c4f1b38c3..3b5de428a 100644 --- a/app/models/billing/promotion.rb +++ b/app/models/billing/promotion.rb @@ -8,4 +8,8 @@ def promo_code end scope :active, -> { where('expires_at > ?', DateTime.now) } + + def active? + expires_at > DateTime.now + end end diff --git a/app/models/book.rb b/app/models/book.rb new file mode 100644 index 000000000..984de74ae --- /dev/null +++ b/app/models/book.rb @@ -0,0 +1,91 @@ +class Book < ApplicationRecord + acts_as_paranoid + + belongs_to :user + belongs_to :universe, optional: true + + has_many :book_documents, -> { order(position: :asc) }, dependent: :destroy + has_many :documents, through: :book_documents + has_many :word_count_updates, as: :entity + + include HasPrivacy + include HasPageTags + include BelongsToUniverse + include HasImageUploads + + include Authority::Abilities + self.authorizer_name = 'BookAuthorizer' + + enum status: { writing: 0, revising: 1, submitting: 2, published: 3 } + + validates :name, presence: true + + # Track word count changes for description and blurb fields + WORD_COUNT_TRACKED_FIELDS = %w[description blurb].freeze + + after_commit :enqueue_word_count_update, if: :word_count_fields_changed? + + def word_count_fields_changed? + (saved_changes.keys & WORD_COUNT_TRACKED_FIELDS).any? + end + + def enqueue_word_count_update + CacheBookWordCountJob.perform_later(self.id) + rescue StandardError => e + Rails.logger.error("Failed to enqueue CacheBookWordCountJob: #{e.message}") + end + + # Display helpers (like Timeline/Document patterns) + def self.icon + 'menu_book' + end + + def self.color + 'bg-emerald-500' + end + + def self.text_color + 'text-emerald-600' + end + + def self.hex_color + '#10b981' + end + + # Instance methods that delegate to class methods + # Needed for views that call book.color, book.icon, etc. + def color + Book.color + end + + def icon + Book.icon + end + + def text_color + Book.text_color + end + + def hex_color + Book.hex_color + end + + + + def archive! + update(archived_at: Time.current) + end + + def unarchive! + update(archived_at: nil) + end + + def archived? + archived_at.present? + end + + scope :unarchived, -> { where(archived_at: nil) } + scope :archived, -> { where.not(archived_at: nil) } + + private +end diff --git a/app/models/book_document.rb b/app/models/book_document.rb new file mode 100644 index 000000000..e5b9d3842 --- /dev/null +++ b/app/models/book_document.rb @@ -0,0 +1,8 @@ +class BookDocument < ApplicationRecord + acts_as_list scope: :book_id + + belongs_to :book, touch: true + belongs_to :document + + validates :document_id, uniqueness: { scope: :book_id, message: 'is already in this book' } +end diff --git a/app/models/concerns/has_attributes.rb b/app/models/concerns/has_attributes.rb index c69dd5f71..dcdf4f9b7 100644 --- a/app/models/concerns/has_attributes.rb +++ b/app/models/concerns/has_attributes.rb @@ -11,34 +11,17 @@ def self.create_default_attribute_categories(user) # Don't create any attribute categories for AttributeCategories or AttributeFields that share the ContentController return [] if ['attribute_category', 'attribute_field'].include?(content_name) - YAML.load_file(Rails.root.join('config', 'attributes', "#{content_name}.yml")).map do |category_name, defaults| - # First, query for the category to see if it already exists - category = user.attribute_categories.find_or_initialize_by( - entity_type: self.content_name, - name: category_name.to_s - ) - creating_new_category = category.new_record? - - # If the category didn't already exist, go ahead and set defaults on it and save - if creating_new_category - category.label = defaults[:label] - category.icon = defaults[:icon] - category.save! - end - - # If we created this category for the first time, we also want to make sure we create its default fields, too - if creating_new_category && defaults.key?(:attributes) - category.attribute_fields << defaults[:attributes].map do |field| - af_field = category.attribute_fields.with_deleted.create!( - old_column_source: field[:name], - user: user, - field_type: field[:field_type].presence || "text_area", - label: field[:label].presence || 'Untitled field' - ) - af_field - end - end - end.compact + # Use the new TemplateInitializationService for consistency + template_service = TemplateInitializationService.new(user, content_name) + + # Only create template if it doesn't already exist (for new users) + if template_service.template_exists? + # Return existing categories + user.attribute_categories.where(entity_type: content_name).order(:position) + else + # Create new default template + template_service.initialize_default_template! + end end def self.attribute_categories(user, show_hidden: false) @@ -109,7 +92,7 @@ def self.attribute_categories(user, show_hidden: false) .user .attribute_categories .where(entity_type: self.content_name, hidden: acceptable_hidden_values) - .eager_load(attribute_fields: :attribute_values) + .eager_load(:attribute_fields) .order('attribute_categories.position, attribute_categories.created_at, attribute_categories.id') # We need to do something like this, but... not this. diff --git a/app/models/concerns/has_content.rb b/app/models/concerns/has_content.rb index c12f6f4cf..089aeb045 100644 --- a/app/models/concerns/has_content.rb +++ b/app/models/concerns/has_content.rb @@ -132,7 +132,14 @@ def public_content Rails.application.config.content_types[:all].each do |type| relation = type.name.downcase.pluralize.to_sym # :characters - content_value[relation] = send(relation).is_public + pages = send(relation).is_public + # Eager load universe and images for content types that belong to a universe (not Universe itself) + if type.name != 'Universe' + pages = pages.includes(:universe, :image_uploads) + else + pages = pages.includes(:image_uploads) + end + content_value[relation] = pages end content_value @@ -157,5 +164,24 @@ def recent_content_list(limit: 10) .first(limit) .map { |page_data| ContentPage.new(page_data) } end + + # Optimized method for getting recent public content + def recent_public_content_list(limit: 10) + @user_recent_public_content_list ||= begin + recent_content = [] + + Rails.application.config.content_types[:all].each do |content_type| + relation = content_type.name.downcase.pluralize.to_sym + recent = send(relation) + .is_public + .order(updated_at: :desc) + .limit(limit) + + recent_content.concat(recent.to_a) + end + + recent_content.sort_by(&:updated_at).reverse.first(limit) + end + end end end diff --git a/app/models/concerns/has_image_uploads.rb b/app/models/concerns/has_image_uploads.rb index 6b6d89759..3a08c2ed7 100644 --- a/app/models/concerns/has_image_uploads.rb +++ b/app/models/concerns/has_image_uploads.rb @@ -5,9 +5,34 @@ module HasImageUploads included do has_many :image_uploads, as: :content + accepts_nested_attributes_for :image_uploads, allow_destroy: true # todo: dependent: :destroy_async # todo: destroy from s3 on destroy + def primary_image + # self.image_uploads.find_by(primary: true) || self.image_uploads.first + self.image_uploads.first.presence || [header_asset_for(self.class.name)] + end + + def extract_image_url(upload, format = :medium) + return nil unless upload + + # Fast Paperclip check: ensure an underlying file is recorded in the DB + # before asking for a URL, dodging the default 'missing.png' return. + if upload.respond_to?(:src_file_name) && upload.src_file_name.blank? + return nil + end + + # Future-proofing for upcoming ActiveStorage transition + if upload.respond_to?(:upload) && upload.upload.respond_to?(:attached?) && !upload.upload.attached? + return nil + end + + url = upload.try(:src, format).to_s + return nil if url.blank? || url.include?('missing.png') + url + end + def public_image_uploads self.image_uploads.where(privacy: 'public').presence || [header_asset_for(self.class.name)] end @@ -26,21 +51,27 @@ def random_image_including_private(format: :medium) # If no pinned image, fall back to random selection if result.nil? - result = image_uploads.sample.try(:src, format) + result = extract_image_url(image_uploads.sample, format) # If we don't have any uploaded images, we look for saved Basil commissions if result.nil? && respond_to?(:basil_commissions) - result = basil_commissions.where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image) + basil_image = basil_commissions.where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image) + # Handle Active Storage attachments properly + if basil_image.present? && basil_image.respond_to?(:url) + begin + result = basil_image.url + rescue + result = nil + end + end end end - # Cache the result - @random_image_including_private_cache[key] = result + # Cache the result (only cache non-nil results to avoid issues) + @random_image_including_private_cache[key] = result if result.present? - # Finally, if we have no image upload, we return the default image for this type - result = result.presence || header_asset_for(self.class.name) - - result + # Finally, if we have no valid image URL, return the default image for this type + result.presence || header_asset_for(self.class.name) end def first_public_image(format = :medium) @@ -49,7 +80,7 @@ def first_public_image(format = :medium) return pinned if pinned.present? # Fall back to first public image - public_image_uploads.first.try(:src, format).presence || header_asset_for(self.class.name) + extract_image_url(public_image_uploads.first, format).presence || header_asset_for(self.class.name) end def random_public_image(format = :medium) @@ -58,19 +89,38 @@ def random_public_image(format = :medium) return pinned if pinned.present? # Fall back to random public image - public_image_uploads.sample.try(:src, format).presence || header_asset_for(self.class.name) + extract_image_url(public_image_uploads.sample, format).presence || header_asset_for(self.class.name) + end + + def custom_public_thumbnail_url(format: :medium) + url = first_public_image(format) + fallback_url = header_asset_for(self.class.name) + url == fallback_url ? nil : url end # Returns the pinned image upload (or nil if none pinned) def pinned_image_upload(format = :medium) # First check standard image uploads pinned_upload = image_uploads.pinned.first - return pinned_upload.try(:src, format) if pinned_upload.present? + if pinned_upload.present? + url = extract_image_url(pinned_upload, format) + return url if url.present? + end # Then check basil commissions if respond_to?(:basil_commissions) pinned_commission = basil_commissions.pinned.where.not(saved_at: nil).includes([:image_attachment]).first - return pinned_commission.try(:image) if pinned_commission.present? + if pinned_commission.present? + basil_image = pinned_commission.try(:image) + # Handle Active Storage attachments properly + if basil_image.present? && basil_image.respond_to?(:url) + begin + return basil_image.url + rescue + return nil + end + end + end end nil @@ -79,11 +129,24 @@ def pinned_image_upload(format = :medium) # Returns the pinned public image (or nil if none pinned) def pinned_public_image(format = :medium) pinned_upload = image_uploads.pinned.where(privacy: 'public').first - return pinned_upload.try(:src, format) if pinned_upload.present? + if pinned_upload.present? + url = extract_image_url(pinned_upload, format) + return url if url.present? + end if respond_to?(:basil_commissions) pinned_commission = basil_commissions.pinned.where.not(saved_at: nil).includes([:image_attachment]).first - return pinned_commission.try(:image) if pinned_commission.present? + if pinned_commission.present? + basil_image = pinned_commission.try(:image) + # Handle Active Storage attachments properly + if basil_image.present? && basil_image.respond_to?(:url) + begin + return basil_image.url + rescue + return nil + end + end + end end nil @@ -98,15 +161,16 @@ def pinned_or_random_image_including_private(format: :medium) random_image_including_private(format: format) end + # Returns a custom user image (pinned, uploaded, or basil generated) + # but explicitly returns nil instead of the generic header placeholder. + # Useful for UI elements that should fallback to an icon instead of a generic header image. + def custom_thumbnail_url(format: :medium) + url = pinned_or_random_image_including_private(format: format) + url == header_asset_for(self.class.name) ? nil : url + end + def header_asset_for(class_name) - # Since we use this as a fallback image on SEO content (for example, Twitter cards for shared notebook pages), - # we need to include the full protocol + domain + path to ensure they will display the image. A relative path - # will not work. - # - # For direct view rendering, we use the relative asset path which works better with image_tag - Rails.env.production? ? - "https://www.notebook.ai" + ActionController::Base.helpers.asset_url("card-headers/#{class_name.downcase.pluralize}.webp") : - ActionController::Base.helpers.asset_path("card-headers/#{class_name.downcase.pluralize}.webp") + "card-headers/#{class_name.downcase.pluralize}.webp" end end end diff --git a/app/models/concerns/has_parseable_text.rb b/app/models/concerns/has_parseable_text.rb index bbc8c7395..bd878cc01 100644 --- a/app/models/concerns/has_parseable_text.rb +++ b/app/models/concerns/has_parseable_text.rb @@ -38,7 +38,29 @@ def sentences_with_newlines end def words - @words ||= plaintext.downcase.gsub(/[^\s\w\d']/, '').split(' ') + @words ||= begin + text = plaintext.downcase + + # Split on forward slashes (matches WordCountService behavior) + # Preserve dates like 01/02/2024 by temporarily replacing them + text = text.gsub(/(\d{1,2})\/(\d{1,2})(\/\d{2,4})?/) { |m| m.gsub('/', '-SLASH-') } + text = text.gsub('/', ' ') + text = text.gsub('-SLASH-', '/') + + # Split on backslashes + text = text.gsub('\\', ' ') + + # Remove dotted lines, dashed lines, underscores (standalone) + text = text.gsub(/\.{2,}/, ' ') # ... or .... + text = text.gsub(/-{2,}/, ' ') # -- or --- + text = text.gsub(/_{2,}/, ' ') # __ or ___ + + # Remove non-word characters except apostrophes (for contractions) + text = text.gsub(/[^\s\w\d']/, '') + + # Split on whitespace and filter empty strings + text.split(/\s+/).reject(&:blank?) + end end def pages diff --git a/app/models/concerns/has_parts_of_speech.rb b/app/models/concerns/has_parts_of_speech.rb index 689a4f817..71e862ede 100644 --- a/app/models/concerns/has_parts_of_speech.rb +++ b/app/models/concerns/has_parts_of_speech.rb @@ -75,7 +75,7 @@ def interrogatives end def numbers - @numbers ||= text.strip + @numbers ||= plaintext.strip .split(' ') .select { |w| is_numeric?(w) } .uniq diff --git a/app/models/concerns/is_content_page.rb b/app/models/concerns/is_content_page.rb index 36b74a73a..45fd07da3 100644 --- a/app/models/concerns/is_content_page.rb +++ b/app/models/concerns/is_content_page.rb @@ -20,7 +20,7 @@ module IsContentPage has_many :basil_commissions, as: :entity, dependent: :destroy - has_many :word_count_updates, as: :entity, dependent: :destroy + has_many :word_count_updates, as: :entity def latest_word_count_cache word_count_updates.order('for_date DESC').limit(1).first.try(:word_count) || 0 end @@ -84,5 +84,19 @@ def self.hex_color_for(user) # self.color # end # end + + # Instance methods that delegate to class methods + # This allows templates to call content_page.color instead of content_page.class.color + def color + self.class.color + end + + def text_color + self.class.text_color + end + + def icon + self.class.icon + end end end diff --git a/app/models/documents/document.rb b/app/models/documents/document.rb index 684c97168..7d3a798a8 100644 --- a/app/models/documents/document.rb +++ b/app/models/documents/document.rb @@ -1,4 +1,5 @@ class Document < ApplicationRecord + include Rails.application.routes.url_helpers acts_as_paranoid belongs_to :user, optional: true @@ -11,14 +12,44 @@ class Document < ApplicationRecord has_many :document_revisions, dependent: :destroy after_update :save_document_revision! + has_many :book_documents, dependent: :destroy + has_many :books, through: :book_documents + include HasParseableText include HasPartsOfSpeech include HasImageUploads include HasPageTags include BelongsToUniverse + include HasPrivacy belongs_to :folder, optional: true + # Status enum for document workflow tracking + enum status: { + idea: 0, + draft: 1, + writing: 2, + revising: 3, + editing: 4, + submitting: 5, + published: 6, + complete: 7 + } + + # Returns status options for select dropdowns: [['Display Label', 'value'], ...] + def self.status_options + [ + ['Idea', 'idea'], + ['Draft', 'draft'], + ['Writing', 'writing'], + ['Revising', 'revising'], + ['Editing', 'editing'], + ['Submitting', 'submitting'], + ['Published', 'published'], + ['Complete', 'complete'] + ] + end + # TODO: include IsContentPage ? include Authority::Abilities @@ -27,19 +58,35 @@ class Document < ApplicationRecord attr_accessor :tagged_text # Duplicated from is_content_page since we don't include that here yet - has_many :word_count_updates, as: :entity, dependent: :destroy + has_many :word_count_updates, as: :entity def latest_word_count_cache word_count_updates.order('for_date DESC').limit(1).first.try(:word_count) || 0 end KEYS_TO_TRIGGER_REVISION_ON_CHANGE = %w(title body synopsis notes_text) + # Archive scopes and methods (matching IsContentPage pattern) + scope :unarchived, -> { where(archived_at: nil) } + scope :archived, -> { where.not(archived_at: nil) } + + def archive! + update!(archived_at: DateTime.now) + end + + def unarchive! + update!(archived_at: nil) + end + + def archived? + !archived_at.nil? + end + def self.color - 'teal' + 'teal bg-teal-500' end def self.text_color - 'teal-text' + 'teal-text text-teal-500' end def color @@ -78,6 +125,14 @@ def universe_field_value # TODO: populate value from cache when documents belong to a universe end + def view_path + document_path(self.id) + end + + def edit_path + edit_document_path(self.id) + end + def analyze! # Create an analysis placeholder to show the user one is queued, # then process it async. @@ -90,7 +145,12 @@ def analyze! def save_document_revision! if (saved_changes.keys & KEYS_TO_TRIGGER_REVISION_ON_CHANGE).any? - SaveDocumentRevisionJob.perform_later(self.id) + begin + SaveDocumentRevisionJob.perform_later(self.id) + rescue RedisClient::CannotConnectError, Redis::CannotConnectError => e + # Log the error but don't fail the save - document revisions are not critical + Rails.logger.warn "Could not save document revision due to Redis connection error: #{e.message}" + end end end @@ -101,29 +161,7 @@ def word_count def computed_word_count return 0 unless self.body && self.body.present? - # Settings: https://github.com/diasks2/word_count_analyzer - # TODO: move this into analysis services & call that here - if false && self.body.length <= 10_000 - WordCountAnalyzer::Counter.new( - ellipsis: 'no_special_treatment', - hyperlink: 'count_as_one', - contraction: 'count_as_one', - hyphenated_word: 'count_as_one', - date: 'no_special_treatment', - number: 'count', - numbered_list: 'ignore', - xhtml: 'remove', - forward_slash: 'count_as_multiple_except_dates', - backslash: 'count_as_one', - dotted_line: 'ignore', - dashed_line: 'ignore', - underscore: 'ignore', - stray_punctuation: 'ignore' - ).count(self.body) - else - # For really long documents, use a faster approach to estimate word count - self.body.scan(/\b\w+\b/).count - end + WordCountService.count(self.body) end def reading_estimate diff --git a/app/models/documents/document_analysis.rb b/app/models/documents/document_analysis.rb index 6f6669841..f3bf085e9 100644 --- a/app/models/documents/document_analysis.rb +++ b/app/models/documents/document_analysis.rb @@ -14,6 +14,7 @@ class DocumentAnalysis < ApplicationRecord # usage: analysis.pos_percentage(:adjective) => 23.4 def pos_percentage(pos_symbol) + return 0.0 if word_count.nil? || word_count == 0 (send(pos_symbol.to_s + '_count').to_f / word_count * 100).round(2) end @@ -24,4 +25,16 @@ def complete? def has_sentiment_scores? [joy_score, sadness_score, fear_score, disgust_score, anger_score].compact.any? end + + def self.icon + 'bar_chart' + end + + def self.text_color + 'text-orange-500' + end + + def self.color + 'bg-orange-500' + end end \ No newline at end of file diff --git a/app/models/documents/document_entity.rb b/app/models/documents/document_entity.rb index bc0216888..806d2883b 100644 --- a/app/models/documents/document_entity.rb +++ b/app/models/documents/document_entity.rb @@ -1,6 +1,10 @@ class DocumentEntity < ApplicationRecord belongs_to :entity, polymorphic: true, optional: true belongs_to :document_analysis, optional: true + has_one :document, through: :document_analysis + + # Prevent linking the same entity multiple times to the same document + validates :entity_id, uniqueness: { scope: [:document_analysis_id, :entity_type], message: "is already linked to this document" }, if: -> { entity_id.present? } after_create :match_notebook_page!, if: Proc.new { |de| de.entity_id.nil? } @@ -49,15 +53,15 @@ def linked_name_if_possible end def dominant_emotion - return { unknown: 1 } if emotions.values.uniq == [0] + return [[:unknown, 1]] if emotions.values.compact.empty? || emotions.values.uniq == [0] - emotions.sort_by { |emotion, score| score }.reverse + emotions.sort_by { |emotion, score| score.to_f }.reverse end def recessive_emotion - return { unknown: 1 } if emotions.values.uniq == [0] + return [[:unknown, 1]] if emotions.values.compact.empty? || emotions.values.uniq == [0] - emotions.sort_by { |emotion, score| score } + emotions.sort_by { |emotion, score| score.to_f } end def emotions diff --git a/app/models/folder.rb b/app/models/folder.rb index 2d9d7bb76..83e2a371c 100644 --- a/app/models/folder.rb +++ b/app/models/folder.rb @@ -4,16 +4,30 @@ class Folder < ApplicationRecord belongs_to :parent_folder, optional: true, class_name: Folder.name, foreign_key: :parent_folder_id belongs_to :user + before_validation :set_default_title + + private + + def set_default_title + self.title = "Unnamed Folder" if title.blank? + end + + public + def child_folders Folder.where(user: self.user, context: self.context, parent_folder_id: self.id) end def self.color - 'lighten-1 teal' + 'bg-blue-600' + end + + def self.hex_color + '#0000ff' end def self.text_color - 'text-lighten-1 teal-text' + 'text-blue-600' end def self.icon diff --git a/app/models/page_collections/page_collection.rb b/app/models/page_collections/page_collection.rb index 6c9f53eb0..4115ab2b4 100644 --- a/app/models/page_collections/page_collection.rb +++ b/app/models/page_collections/page_collection.rb @@ -43,6 +43,10 @@ def contributors User.where(id: accepted_submissions.pluck(:user_id) - [user.id]) end + def editor_picks_ordered + accepted_submissions.editor_picks.order(:editor_pick_position).limit(6) + end + def random_public_image return cover_image if cover_image.present? @@ -54,6 +58,17 @@ def random_public_image ActionController::Base.helpers.asset_path("card-headers/#{self.class.name.downcase.pluralize}.webp") end + def random_image_including_private(format) + return cover_image if cover_image.present? + + if header_image.attachment.present? + return header_image + end + + # If all else fails, fall back on default header + ActionController::Base.helpers.asset_path("card-headers/#{self.class.name.downcase.pluralize}.webp") + end + def first_public_image random_public_image end @@ -71,11 +86,15 @@ def followed_by?(user) serialize :page_types, Array def self.color - 'brown' + 'brown bg-brown-800' + end + + def text_color + PageCollection.text_color end def self.text_color - 'brown-text' + 'text-brown-800 brown-text' end def self.hex_color @@ -83,6 +102,10 @@ def self.hex_color end def self.icon - 'layers' + 'auto_stories' + end + + def page_type + 'Collection' end end diff --git a/app/models/page_collections/page_collection_submission.rb b/app/models/page_collections/page_collection_submission.rb index 766a02a92..faeb9de4e 100644 --- a/app/models/page_collections/page_collection_submission.rb +++ b/app/models/page_collections/page_collection_submission.rb @@ -12,6 +12,12 @@ class PageCollectionSubmission < ApplicationRecord after_create :cache_content_name scope :accepted, -> { where.not(accepted_at: nil).uniq(&:page_collection_id) } + scope :editor_picks, -> { where.not(editor_pick_position: nil) } + + validates :editor_pick_position, + inclusion: { in: 1..6 }, + uniqueness: { scope: :page_collection_id }, + allow_nil: true def accept! update(accepted_at: DateTime.current) @@ -86,6 +92,10 @@ def create_submission_notification end end + def editor_pick? + editor_pick_position.present? + end + private def cache_content_name diff --git a/app/models/page_data/attribute.rb b/app/models/page_data/attribute.rb index a08f3cc22..888ce8910 100644 --- a/app/models/page_data/attribute.rb +++ b/app/models/page_data/attribute.rb @@ -5,6 +5,12 @@ class Attribute < ApplicationRecord belongs_to :attribute_field belongs_to :entity, polymorphic: true, touch: true + validates :attribute_field_id, uniqueness: { + scope: [:entity_type, :entity_id], + conditions: -> { where(deleted_at: nil) }, + message: "already has a value for this entity" + } + include HasChangelog include Authority::Abilities @@ -19,14 +25,17 @@ class Attribute < ApplicationRecord end end - after_commit do - if saved_changes.key?('value') - # Cache the updated word count on this attribute - CacheAttributeWordCountJob.perform_later(self.id) + after_commit :enqueue_word_count_jobs, on: [:create, :update] - # Cache the updated word count on the page this attribute belongs to - CacheSumAttributeWordCountJob.perform_later(self.entity_type, self.entity_id) - end + def enqueue_word_count_jobs + # Cache the updated word count on this attribute + CacheAttributeWordCountJob.perform_later(self.id) + + # Cache the updated word count on the page this attribute belongs to + CacheSumAttributeWordCountJob.perform_later(self.entity_type, self.entity_id) + rescue RedisClient::CannotConnectError, Redis::CannotConnectError => e + Rails.logger.error "[WordCount] Redis unavailable - word count jobs not enqueued for Attribute##{id}: #{e.message}" + # No inline fallback - would be DDoS risk with large documents end after_save do diff --git a/app/models/page_data/image_upload.rb b/app/models/page_data/image_upload.rb index 1deada079..4c4bfdb7d 100644 --- a/app/models/page_data/image_upload.rb +++ b/app/models/page_data/image_upload.rb @@ -2,13 +2,20 @@ class ImageUpload < ApplicationRecord belongs_to :user, optional: true belongs_to :content, polymorphic: true + # Inherit user_id from parent content when created through nested attributes + before_validation :inherit_user_id, on: :create + + def inherit_user_id + self.user_id ||= content&.user_id + end + # Add scopes for image ordering scope :pinned, -> { where(pinned: true) } scope :ordered, -> { order(:position) } # This is the old way we uploaded files -- now we're transitioning to ActiveStorage's has_one_attached has_attached_file :src, - path: 'content/uploads/:style/:filename', + **(Rails.env.production? ? { path: 'content/uploads/:style/:filename' } : {}), styles: { thumb: '100x100>', small: '190x190#', diff --git a/app/models/page_types/building.rb b/app/models/page_types/building.rb index bca68f64a..ae1da37e7 100644 --- a/app/models/page_types/building.rb +++ b/app/models/page_types/building.rb @@ -23,11 +23,11 @@ class Building < ActiveRecord::Base relates :district_schools, with: :building_schools def self.color - 'blue-grey' + 'blue-grey bg-gray-600' end def self.text_color - 'blue-grey-text' + 'blue-grey-text text-gray-600' end def self.hex_color diff --git a/app/models/page_types/character.rb b/app/models/page_types/character.rb index 10fb2ed77..c387ec55d 100644 --- a/app/models/page_types/character.rb +++ b/app/models/page_types/character.rb @@ -49,11 +49,11 @@ def self.content_name end def self.color - 'red' + 'bg-red-500' end def self.text_color - 'red-text' + 'text-red-500' end def self.hex_color diff --git a/app/models/page_types/condition.rb b/app/models/page_types/condition.rb index 4a12af6ab..6dd767179 100644 --- a/app/models/page_types/condition.rb +++ b/app/models/page_types/condition.rb @@ -14,11 +14,11 @@ class Condition < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'text-darken-1 lime' + 'text-darken-1 lime bg-lime-600' end def self.text_color - 'text-darken-1 lime-text' + 'text-darken-1 lime-text text-lime-600' end def self.hex_color diff --git a/app/models/page_types/content_page.rb b/app/models/page_types/content_page.rb index 770c2f28d..03a95d5f3 100644 --- a/app/models/page_types/content_page.rb +++ b/app/models/page_types/content_page.rb @@ -11,6 +11,7 @@ class ContentPage < ApplicationRecord # Returns a single image for use in previews/cards, prioritizing pinned images # This method keeps the original behavior of prioritizing pinned images for thumbnails/previews + # TODO: this is gonna be an N+1 query any time we display a list of ContentPages with images def random_image_including_private(format: :small) # Always prioritize pinned images first for preview cards pinned_image = ImageUpload.where(content_type: self.page_type, content_id: self.id, pinned: true).first @@ -30,6 +31,18 @@ def random_image_including_private(format: :small) ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.webp") end + def primary_image(format: :small) + ImageUpload.where(content_type: self.page_type, content_id: self.id).first.try(:src, format) \ + || BasilCommission.where(entity_type: self.page_type, entity_id: self.id).where.not(saved_at: nil).includes([:image_attachment]).first.try(:image) \ + || ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.webp") + end + + def custom_thumbnail_url(format: :small) + url = random_image_including_private(format: format) + fallback_url = ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.webp") + url == fallback_url ? nil : url + end + def icon self.page_type.constantize.icon end @@ -43,7 +56,15 @@ def text_color end def favorite? - !!favorite + # Handle different formats that might come from SQL queries + case favorite + when true, 1, "1", "true" + true + when false, 0, "0", "false", nil + false + else + !!favorite + end end def view_path diff --git a/app/models/page_types/continent.rb b/app/models/page_types/continent.rb index b74e62dce..649c1b362 100644 --- a/app/models/page_types/continent.rb +++ b/app/models/page_types/continent.rb @@ -30,11 +30,11 @@ def description end def self.color - 'lighten-1 text-lighten-1 green' + 'lighten-1 text-lighten-1 green bg-green-700' end def self.text_color - 'text-lighten-1 green-text' + 'text-lighten-1 green-text text-green-700' end def self.hex_color diff --git a/app/models/page_types/country.rb b/app/models/page_types/country.rb index 72a04a766..2e6ebb91f 100644 --- a/app/models/page_types/country.rb +++ b/app/models/page_types/country.rb @@ -34,11 +34,11 @@ def self.content_name end def self.color - 'lighten-2 text-lighten-2 brown' + 'lighten-2 text-lighten-2 brown bg-brown-700' end def self.text_color - 'text-lighten-2 brown-text' + 'text-lighten-2 brown-text text-brown-700' end def self.hex_color diff --git a/app/models/page_types/creature.rb b/app/models/page_types/creature.rb index 619254862..363146f50 100644 --- a/app/models/page_types/creature.rb +++ b/app/models/page_types/creature.rb @@ -33,11 +33,11 @@ def description end def self.color - 'brown' + 'brown bg-amber-900' end def self.text_color - 'brown-text' + 'brown-text text-amber text-amber-900' end def self.hex_color diff --git a/app/models/page_types/deity.rb b/app/models/page_types/deity.rb index 696bfc8d1..d8c323ae5 100644 --- a/app/models/page_types/deity.rb +++ b/app/models/page_types/deity.rb @@ -34,11 +34,11 @@ def description end def self.color - 'text-lighten-4 blue' + 'text-lighten-4 blue bg-blue-300' end def self.text_color - 'text-lighten-4 blue-text' + 'text-lighten-4 blue-text text-blue-300' end def self.hex_color diff --git a/app/models/page_types/flora.rb b/app/models/page_types/flora.rb index d8249ef31..2d03c80dc 100644 --- a/app/models/page_types/flora.rb +++ b/app/models/page_types/flora.rb @@ -29,11 +29,11 @@ def self.content_name end def self.color - 'text-lighten-2 lighten-2 teal' + 'text-lighten-2 lighten-2 teal bg-lime-700' end def self.text_color - 'text-lighten-2 teal-text' + 'text-lighten-2 teal-text text-lime-700' end def self.hex_color diff --git a/app/models/page_types/food.rb b/app/models/page_types/food.rb index 2931d350c..7179eb7fb 100644 --- a/app/models/page_types/food.rb +++ b/app/models/page_types/food.rb @@ -14,11 +14,11 @@ class Food < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'red' + 'red bg-red-400' end def self.text_color - 'red-text' + 'red-text text-red-400' end def self.hex_color diff --git a/app/models/page_types/government.rb b/app/models/page_types/government.rb index 74f56450f..6e47517f9 100644 --- a/app/models/page_types/government.rb +++ b/app/models/page_types/government.rb @@ -24,11 +24,11 @@ def description end def self.color - 'darken-2 green' + 'darken-2 green bg-amber-600' end def self.text_color - 'green-text' + 'green-text text-amber-600' end def self.hex_color diff --git a/app/models/page_types/group.rb b/app/models/page_types/group.rb index 9875f2a06..02ffcee50 100644 --- a/app/models/page_types/group.rb +++ b/app/models/page_types/group.rb @@ -37,11 +37,11 @@ def description end def self.color - 'cyan' + 'cyan bg-cyan-500' end def self.text_color - 'cyan-text' + 'cyan-text text-cyan-500' end def self.hex_color diff --git a/app/models/page_types/item.rb b/app/models/page_types/item.rb index 4bb42db37..48159d128 100644 --- a/app/models/page_types/item.rb +++ b/app/models/page_types/item.rb @@ -31,11 +31,11 @@ def description end def self.color - 'text-darken-2 amber' + 'text-darken-2 amber bg-amber-600' end def self.text_color - 'text-darken-2 amber-text' + 'text-darken-2 amber-text text-amber-600' end def self.hex_color diff --git a/app/models/page_types/job.rb b/app/models/page_types/job.rb index 9c4b8692a..d7f306d01 100644 --- a/app/models/page_types/job.rb +++ b/app/models/page_types/job.rb @@ -14,11 +14,11 @@ class Job < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'text-lighten-1 brown' + 'text-lighten-1 brown bg-yellow-700' end def self.text_color - 'text-lighten-1 brown-text' + 'text-lighten-1 brown-text text-yellow-700' end def self.hex_color diff --git a/app/models/page_types/landmark.rb b/app/models/page_types/landmark.rb index 110474697..13dd96e86 100644 --- a/app/models/page_types/landmark.rb +++ b/app/models/page_types/landmark.rb @@ -28,11 +28,11 @@ def self.content_name end def self.color - 'text-lighten-1 lighten-1 orange' + 'text-lighten-1 lighten-1 orange bg-orange-600' end def self.text_color - 'text-lighten-1 orange-text' + 'text-lighten-1 orange-text text-orange-600' end def self.hex_color diff --git a/app/models/page_types/language.rb b/app/models/page_types/language.rb index 0ff155b03..14c2d8ff2 100644 --- a/app/models/page_types/language.rb +++ b/app/models/page_types/language.rb @@ -20,11 +20,11 @@ def description end def self.color - 'blue' + 'blue bg-cyan-700' end def self.text_color - 'blue-text' + 'blue-text text-cyan-700' end def self.hex_color diff --git a/app/models/page_types/location.rb b/app/models/page_types/location.rb index 00410e5d8..e427cbdf8 100644 --- a/app/models/page_types/location.rb +++ b/app/models/page_types/location.rb @@ -39,11 +39,11 @@ def self.icon end def self.color - 'green' + 'green bg-green-500' end def self.text_color - 'green-text' + 'green-text text-green-500' end def self.hex_color diff --git a/app/models/page_types/lore.rb b/app/models/page_types/lore.rb index 914f83e12..c4210b153 100644 --- a/app/models/page_types/lore.rb +++ b/app/models/page_types/lore.rb @@ -44,11 +44,11 @@ class Lore < ActiveRecord::Base relates :related_lores, with: :lore_related_lores def self.color - 'text-lighten-2 lighten-1 orange' + 'text-lighten-2 lighten-1 orange bg-teal-600' end def self.text_color - 'text-lighten-2 orange-text' + 'text-lighten-2 orange-text text-teal-600' end def self.hex_color diff --git a/app/models/page_types/magic.rb b/app/models/page_types/magic.rb index 0486b39ae..a8deaea8a 100644 --- a/app/models/page_types/magic.rb +++ b/app/models/page_types/magic.rb @@ -22,11 +22,11 @@ def description end def self.color - 'orange' + 'orange bg-yellow-500' end def self.text_color - 'orange-text' + 'orange-text text-yellow-500' end def self.hex_color diff --git a/app/models/page_types/planet.rb b/app/models/page_types/planet.rb index 85e5144ab..17d376b00 100644 --- a/app/models/page_types/planet.rb +++ b/app/models/page_types/planet.rb @@ -31,11 +31,11 @@ def description end def self.color - 'text-lighten-2 blue' + 'text-lighten-2 blue bg-lime-500' end def self.text_color - 'text-lighten-2 blue-text' + 'text-lighten-2 blue-text text-lime-500' end def self.hex_color diff --git a/app/models/page_types/race.rb b/app/models/page_types/race.rb index dc78c5178..2890e7fad 100644 --- a/app/models/page_types/race.rb +++ b/app/models/page_types/race.rb @@ -28,11 +28,11 @@ def description end def self.color - 'darken-2 light-green' + 'darken-2 light-green bg-indigo-500' end def self.text_color - 'text-darken-2 light-green-text' + 'text-darken-2 light-green-text text-indigo-500' end def self.hex_color diff --git a/app/models/page_types/religion.rb b/app/models/page_types/religion.rb index bff0bd4c6..0c8375967 100644 --- a/app/models/page_types/religion.rb +++ b/app/models/page_types/religion.rb @@ -35,11 +35,11 @@ def description end def self.color - 'indigo' + 'indigo bg-amber-600' end def self.text_color - 'indigo-text' + 'indigo-text text-amber-600' end def self.hex_color diff --git a/app/models/page_types/scene.rb b/app/models/page_types/scene.rb index 483884bc4..4481c3cae 100644 --- a/app/models/page_types/scene.rb +++ b/app/models/page_types/scene.rb @@ -27,11 +27,11 @@ def description end def self.color - 'grey' + 'grey bg-gray-400' end def self.text_color - 'grey-text' + 'grey-text text-gray-400' end def self.hex_color diff --git a/app/models/page_types/school.rb b/app/models/page_types/school.rb index 946d2510e..655792bad 100644 --- a/app/models/page_types/school.rb +++ b/app/models/page_types/school.rb @@ -14,11 +14,11 @@ class School < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'cyan' + 'cyan bg-rose-800' end def self.text_color - 'cyan-text' + 'cyan-text text-rose-800' end def self.hex_color diff --git a/app/models/page_types/sport.rb b/app/models/page_types/sport.rb index cad76ef14..90917dcb4 100644 --- a/app/models/page_types/sport.rb +++ b/app/models/page_types/sport.rb @@ -15,11 +15,11 @@ class Sport < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'orange' + 'orange bg-orange-300' end def self.text_color - 'orange-text' + 'orange-text text-orange-300' end def self.hex_color diff --git a/app/models/page_types/technology.rb b/app/models/page_types/technology.rb index 118fe85da..c8f929f56 100644 --- a/app/models/page_types/technology.rb +++ b/app/models/page_types/technology.rb @@ -28,11 +28,11 @@ def description end def self.color - 'text-darken-2 red' + 'text-darken-2 red bg-fuchsia-500' end def self.text_color - 'text-darken-2 red-text' + 'text-darken-2 red-text text-fuchsia-500' end def self.hex_color diff --git a/app/models/page_types/town.rb b/app/models/page_types/town.rb index b4283951c..d1aa23287 100644 --- a/app/models/page_types/town.rb +++ b/app/models/page_types/town.rb @@ -31,11 +31,11 @@ def self.content_name end def self.color - 'text-lighten-3 lighten-3 purple' + 'text-lighten-3 lighten-3 purple bg-purple-500' end def self.text_color - 'text-lighten-3 purple-text' + 'text-lighten-3 purple-text text-purple-500' end def self.hex_color diff --git a/app/models/page_types/tradition.rb b/app/models/page_types/tradition.rb index 31b3fe36d..8ce178040 100644 --- a/app/models/page_types/tradition.rb +++ b/app/models/page_types/tradition.rb @@ -14,11 +14,11 @@ class Tradition < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'text-lighten-3 lighten-3 red' + 'text-lighten-3 lighten-3 red bg-rose-300' end def self.text_color - 'text-lighten-3 red-text' + 'text-lighten-3 red-text text-rose-300' end def self.hex_color diff --git a/app/models/page_types/universe.rb b/app/models/page_types/universe.rb index d7c6cf206..6be9b31d9 100644 --- a/app/models/page_types/universe.rb +++ b/app/models/page_types/universe.rb @@ -9,6 +9,7 @@ class Universe < ApplicationRecord acts_as_paranoid include IsContentPage + # include HasContent # can't do this because we generate cycles since HasContent relies on Universe already being initialized include Serendipitous::Concern @@ -55,6 +56,7 @@ class Universe < ApplicationRecord has_many :contributors, dependent: :destroy has_many :contributing_users, through: :contributors, source: :user + has_many :books has_many :documents has_many :timelines @@ -75,11 +77,11 @@ def content_count end def self.color - 'purple' + 'purple bg-purple-800' end def self.text_color - 'purple-text' + 'purple-text text-purple-800' end def self.hex_color @@ -93,4 +95,16 @@ def self.icon def self.content_name 'universe' end + + def content + # This is a worse version of the HasContent #content, but... dunno how to include + # that functionality in this class without duplicating it in two places and hard-coding + # the other content type names. TODO come back and fix this + content = {} + Rails.application.config.content_types[:all_non_universe].each do |content_type| + content[content_type.name] = send(content_type.name.downcase.pluralize) + end + + content + end end diff --git a/app/models/page_types/vehicle.rb b/app/models/page_types/vehicle.rb index a5feca86b..b5a3f140e 100644 --- a/app/models/page_types/vehicle.rb +++ b/app/models/page_types/vehicle.rb @@ -14,11 +14,11 @@ class Vehicle < ActiveRecord::Base self.authorizer_name = 'ExtendedContentAuthorizer' def self.color - 'text-lighten-2 lighten-2 green' + 'text-lighten-2 lighten-2 green bg-cyan-400' end def self.text_color - 'text-lighten-2 green-text' + 'text-lighten-2 green-text text-cyan-400' end def self.hex_color diff --git a/app/models/serializers/api_content_serializer.rb b/app/models/serializers/api_content_serializer.rb index 65e0fa810..266fed070 100644 --- a/app/models/serializers/api_content_serializer.rb +++ b/app/models/serializers/api_content_serializer.rb @@ -21,6 +21,18 @@ def initialize(content, include_blank_fields: false) self.attribute_values = Attribute.where(attribute_field_id: self.fields.map(&:id), entity_type: content.page_type, entity_id: content.id).order('created_at desc') self.universe = (content.class.name == Universe.name) ? nil : content.universe + # Preload associations for old link columns to avoid N+1 queries during fallback serialization + link_fields_with_old_assoc = self.fields.select { |f| f.field_type == 'link' && f.old_column_source.present? } + if link_fields_with_old_assoc.any? + associations_to_preload = link_fields_with_old_assoc.map(&:old_column_source).map(&:to_sym).select do |assoc| + content.class.reflect_on_association(assoc).present? + end + + if associations_to_preload.any? + ActiveRecord::Associations::Preloader.new.preload([content], associations_to_preload.uniq) + end + end + self.raw_model = content self.page_tags = content.page_tags.select(:id, :tag, :slug) || [] @@ -42,7 +54,10 @@ def initialize(content, include_blank_fields: false) id: category.id, label: category.label, icon: category.icon, - fields: category.attribute_fields.order(:position).map { |field| + fields: category.attribute_fields.order(:position).reject { |field| + # Filter out private fields from API responses + field.old_column_source == 'private_notes' + }.map { |field| { id: field.id, label: field.label, diff --git a/app/models/serializers/content_serializer.rb b/app/models/serializers/content_serializer.rb index 3abafb1a7..1093a99c2 100644 --- a/app/models/serializers/content_serializer.rb +++ b/app/models/serializers/content_serializer.rb @@ -8,11 +8,13 @@ class ContentSerializer attr_accessor :documents attr_accessor :raw_model - attr_accessor :class_name, :class_color, :class_icon + attr_accessor :class_name, :class_color, :class_text_color, :class_icon attr_accessor :cached_word_count + attr_accessor :created_at, :updated_at attr_accessor :data + attr_accessor :viewing_user # name: 'blah, # categories: [ # { @@ -24,13 +26,26 @@ class ContentSerializer # }] # } - def initialize(content) + def initialize(content, viewing_user: nil) # One query per table; lets not muck with joins yet # self.attribute_values = Attribute.where(entity_type: content.page_type, entity_id: content.id) # self.fields = AttributeField.where(id: self.attribute_values.pluck(:attribute_field_id).uniq) self.categories = content.class.attribute_categories(content.user) self.fields = AttributeField.where(attribute_category_id: self.categories.map(&:id)) self.attribute_values = Attribute.where(attribute_field_id: self.fields.map(&:id), entity_type: content.page_type, entity_id: content.id) + self.viewing_user = viewing_user + + # Preload associations for old link columns to avoid N+1 queries during fallback serialization + link_fields_with_old_assoc = self.fields.select { |f| f.field_type == 'link' && f.old_column_source.present? } + if link_fields_with_old_assoc.any? + associations_to_preload = link_fields_with_old_assoc.map(&:old_column_source).map(&:to_sym).select do |assoc| + content.class.reflect_on_association(assoc).present? + end + + if associations_to_preload.any? + ActiveRecord::Associations::Preloader.new.preload([content], associations_to_preload.uniq) + end + end self.id = content.id self.name = content.name @@ -41,12 +56,15 @@ def initialize(content) self.class_name = content.class.name self.class_color = content.class.color + self.class_text_color = content.class.text_color self.class_icon = content.class.icon self.page_tags = content.page_tags.pluck(:tag) || [] self.documents = content.documents || [] self.cached_word_count = content.cached_word_count + self.created_at = content.created_at + self.updated_at = content.updated_at self.data = { name: content.try(:name), @@ -62,6 +80,14 @@ def initialize(content) icon: category.icon, hidden: !!category.hidden, fields: self.fields.select { |field| field.attribute_category_id == category.id }.map { |field| + # Check if this is a private field (e.g., private_notes) + is_private_field = field.old_column_source == 'private_notes' + # Only the content owner can see private fields + viewer_is_owner = self.viewing_user.present? && content.user_id == self.viewing_user.id + + # Skip private fields entirely if viewer is not the owner + next nil if is_private_field && !viewer_is_owner + { internal_id: field.id, id: field.name, @@ -71,10 +97,11 @@ def initialize(content) position: field.position, value: value_for(field, content), options: field.field_options, - migrated_link: field.migrated_from_legacy, - old_column_source: field.old_column_source + migrated_link: field.migrated_from_legacy, + old_column_source: field.old_column_source, + private: is_private_field } - }.sort do |a, b| + }.compact.sort do |a, b| if a[:position] && b[:position] a[:position] <=> b[:position] diff --git a/app/models/timelines/timeline.rb b/app/models/timelines/timeline.rb index e12d64495..295e57163 100644 --- a/app/models/timelines/timeline.rb +++ b/app/models/timelines/timeline.rb @@ -1,4 +1,6 @@ class Timeline < ApplicationRecord + include Rails.application.routes.url_helpers + acts_as_paranoid include IsContentPage @@ -22,11 +24,11 @@ def self.content_name end def self.color - 'green' + 'green bg-green-500' end def self.text_color - 'green-text' + 'green-text text-green-500' end # Needed because we sometimes munge Timelines in with ContentPages :( @@ -54,7 +56,21 @@ def page_type 'Timeline' end + def view_path + timeline_path(self.id) + end + + def edit_path + edit_timeline_path(self.id) + end + def initialize_first_event - timeline_events.create(title: "Untitled Event", position: 1) + event = timeline_events.build(title: "Untitled Event", position: 1) + event.skip_word_count_update = true + event.save + end + + def total_word_count + (cached_word_count || 0) + timeline_events.sum(:cached_word_count) end end diff --git a/app/models/timelines/timeline_event.rb b/app/models/timelines/timeline_event.rb index 9cbe6c9eb..0ccf2780b 100644 --- a/app/models/timelines/timeline_event.rb +++ b/app/models/timelines/timeline_event.rb @@ -4,8 +4,93 @@ class TimelineEvent < ApplicationRecord belongs_to :timeline, touch: true has_many :timeline_event_entities, dependent: :destroy + has_many :word_count_updates, as: :entity + + include HasPageTags acts_as_list scope: [:timeline_id] + attr_accessor :skip_word_count_update + + # Event type definitions with narrative focus and Material Icons + EVENT_TYPES = { + 'general' => { name: 'General', icon: 'radio_button_checked' }, + 'setup' => { name: 'Setup', icon: 'foundation' }, + 'exposition' => { name: 'Exposition', icon: 'info' }, + 'inciting_incident' => { name: 'Inciting Incident', icon: 'flash_on' }, + 'complication' => { name: 'Complication', icon: 'warning' }, + 'obstacle' => { name: 'Obstacle', icon: 'block' }, + 'conflict' => { name: 'Conflict', icon: 'gavel' }, + 'progress' => { name: 'Progress', icon: 'trending_up' }, + 'revelation' => { name: 'Revelation', icon: 'visibility' }, + 'transformation' => { name: 'Transformation', icon: 'autorenew' }, + 'climax' => { name: 'Climax', icon: 'whatshot' }, + 'resolution' => { name: 'Resolution', icon: 'check_circle' }, + 'aftermath' => { name: 'Aftermath', icon: 'restore' } + }.freeze + + # Validation + validates :event_type, inclusion: { in: EVENT_TYPES.keys } + + # Track word count changes for title and description fields + WORD_COUNT_TRACKED_FIELDS = %w[title description].freeze + + after_commit :enqueue_word_count_update, if: :word_count_fields_changed? + + def word_count_fields_changed? + (saved_changes.keys & WORD_COUNT_TRACKED_FIELDS).any? + end + + def enqueue_word_count_update + # Skip enqueueing a job if explicitly flagged (like during timeline initialization) + return if skip_word_count_update + + CacheTimelineEventWordCountJob.perform_later(self.id) + end + + # Helper methods + def event_type_info + EVENT_TYPES[event_type] || EVENT_TYPES['general'] + end + + def name + title + end + + def event_type_icon + event_type_info[:icon] + end + + def event_type_name + event_type_info[:name] + end + + def event_type_color + colors = { + 'general' => 'bg-gray-500', + 'setup' => 'bg-blue-500', + 'exposition' => 'bg-indigo-500', + 'inciting_incident' => 'bg-yellow-500', + 'complication' => 'bg-orange-500', + 'obstacle' => 'bg-red-400', + 'conflict' => 'bg-red-600', + 'progress' => 'bg-green-500', + 'revelation' => 'bg-purple-500', + 'transformation' => 'bg-pink-500', + 'climax' => 'bg-rose-600', + 'resolution' => 'bg-emerald-500', + 'aftermath' => 'bg-cyan-500' + } + colors[event_type] || colors['general'] + end + + def has_duration? + end_time_label.present? + end + + def display_duration + return time_label if end_time_label.blank? + "#{time_label} - #{end_time_label}" + end # todo move this to a real permissions authorizer def can_be_modified_by?(user) diff --git a/app/models/users/user.rb b/app/models/users/user.rb index e44a506d6..b74ea2a41 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -25,11 +25,14 @@ class User < ApplicationRecord length: { maximum: 20 }, if: Proc.new { |user| user.forums_badge_text_changed? } + validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }, allow_nil: false + has_many :folders has_many :subscriptions, dependent: :destroy has_many :billing_plans, through: :subscriptions def on_premium_plan? - BillingPlan::PREMIUM_IDS.include?(self.selected_billing_plan_id) || active_promo_codes.any? + # Use Ruby filtering on eager-loaded promotions to avoid N+1 queries + BillingPlan::PREMIUM_IDS.include?(self.selected_billing_plan_id) || promotions.any?(&:active?) end has_many :promotions, dependent: :destroy has_many :paypal_invoices @@ -49,21 +52,22 @@ def referrer has_many :user_followings, dependent: :destroy has_many :followed_users, -> { distinct }, through: :user_followings, source: :followed_user + has_many :inverse_user_followings, class_name: 'UserFollowing', foreign_key: :followed_user_id, dependent: :destroy # has_many :followed_by_users, through: :user_followings, source: :user # todo unsure how to actually write this, so we do it manually below def followed_by_users - User.where(id: UserFollowing.where(followed_user_id: self.id).pluck(:user_id)) + User.joins(:user_followings).where(user_followings: { followed_user_id: self.id }) end def followed_by?(user) - followed_by_users.pluck(:id).include?(user.id) + UserFollowing.exists?(user_id: user.id, followed_user_id: self.id) end has_many :user_blockings, dependent: :destroy has_many :blocked_users, through: :user_blockings, source: :blocked_user def blocked_by_users - @cached_blocked_by_users ||= User.where(id: UserBlocking.where(blocked_user_id: self.id).pluck(:user_id)) + @cached_blocked_by_users ||= User.joins(:user_blockings).where(user_blockings: { blocked_user_id: self.id }) end def blocked_by?(user) - blocked_by_users.pluck(:id).include?(user.id) + UserBlocking.exists?(user_id: user.id, blocked_user_id: self.id) end has_many :content_page_shares, dependent: :destroy @@ -93,6 +97,9 @@ def published_in_page_collections has_many :notifications, dependent: :destroy has_many :notice_dismissals, dependent: :destroy + has_many :word_count_updates, dependent: :destroy + has_many :writing_goals, dependent: :destroy + has_many :page_settings_overrides, dependent: :destroy has_one_attached :avatar validates :avatar, attached: false, @@ -156,7 +163,7 @@ def linkable_documents universe_id IN (#{(my_universe_ids + contributable_universe_ids + [-1]).uniq.join(',')}) OR (universe_id IS NULL AND user_id = #{self.id.to_i}) - """).includes([:user]) + """).where(archived_at: nil).includes([:user]) end def linkable_timelines @@ -168,6 +175,7 @@ def linkable_timelines end has_many :documents, dependent: :destroy + has_many :books, dependent: :destroy after_create :initialize_stripe_customer, unless: -> { Rails.env == 'test' } after_create :initialize_referral_code @@ -182,6 +190,35 @@ def createable_content_types Rails.application.config.content_types[:all].select { |c| can_create? c } end + # Returns the current date in the user's configured timezone + def current_date_in_time_zone + Time.current.in_time_zone(time_zone).to_date + end + + def words_written_today + WordCountUpdate.words_written_on_date(self, current_date_in_time_zone) + end + + def current_writing_goals + writing_goals.current + end + + # Deprecated: use current_writing_goals instead + def current_writing_goal + writing_goals.current.first + end + + # Returns the user's daily word goal: the maximum of all active writing goals, + # or a default of 1,000 words if no active goals exist or all goals have nil/0 values + DEFAULT_DAILY_WORD_GOAL = 1000 + def daily_word_goal + goals = current_writing_goals + return DEFAULT_DAILY_WORD_GOAL if goals.empty? + + calculated = goals.map(&:daily_goal).compact.max + calculated.to_i > 0 ? calculated : DEFAULT_DAILY_WORD_GOAL + end + # as_json creates a hash structure, which you then pass to ActiveSupport::json.encode to actually encode the object as a JSON string. # This is different from to_json, which converts it straight to an escaped JSON string, # which is undesireable in a case like this, when we want to modify it @@ -205,27 +242,25 @@ def name self[:name].blank? && self.persisted? ? 'Anonymous author' : self[:name] end - def image_url(size=80) - @cached_user_image_url ||= if avatar.attached? # manually-uploaded avatar - Rails.application.routes.url_helpers.rails_representation_url(avatar.variant(resize_to_limit: [size, size]).processed, only_path: true) - + def image_url(size: 80) + if avatar.attached? # manually-uploaded avatar + Rails.application.routes.url_helpers.rails_representation_url(avatar.variant(resize_to_fill: [size, size]).processed, only_path: true) else # otherwise, grab the default from Gravatar for this email address - gravatar_fallback_url(size) + gravatar_fallback_url(size: size) end - rescue ActiveStorage::FileNotFoundError - gravatar_fallback_url(size) - + gravatar_fallback_url(size: size) rescue ImageProcessing::Error - gravatar_fallback_url(size) + gravatar_fallback_url(size: size) end - def gravatar_fallback_url(size=80) + def gravatar_fallback_url(size: 80) require 'digest/md5' # todo do we actually need to require this all the time? email_md5 = Digest::MD5.hexdigest(email.downcase) "https://www.gravatar.com/avatar/#{email_md5}?d=identicon&s=#{size}".html_safe end + # TODO these (3) can probably all be scopes on the related object, no? def active_subscriptions subscriptions @@ -355,11 +390,11 @@ def self.icon end def self.color - 'green' + 'green bg-green-600' end def self.text_color - 'green-text' + 'green-text text-green-600' end def favorite_page_type_color diff --git a/app/models/users/user_following.rb b/app/models/users/user_following.rb index 741e5a09d..f1cd6a06b 100644 --- a/app/models/users/user_following.rb +++ b/app/models/users/user_following.rb @@ -1,4 +1,6 @@ class UserFollowing < ApplicationRecord - belongs_to :user - belongs_to :followed_user, class_name: User.name + acts_as_paranoid + + belongs_to :user, counter_cache: :following_count + belongs_to :followed_user, class_name: User.name, counter_cache: :followers_count end diff --git a/app/models/word_count_update.rb b/app/models/word_count_update.rb index 1f26dccce..4f59fe5e4 100644 --- a/app/models/word_count_update.rb +++ b/app/models/word_count_update.rb @@ -1,4 +1,231 @@ class WordCountUpdate < ApplicationRecord belongs_to :user - belongs_to :entity, polymorphic: true + belongs_to :entity, polymorphic: true, optional: true + validates :entity, presence: true, unless: :is_manual_adjustment? + + after_commit :check_daily_word_goal, on: [:create, :update] + + def is_manual_adjustment? + entity_type == 'ManualAdjustment' + end + + private + + def check_daily_word_goal + DailyWordGoalNotificationJob.perform_later(user_id) if user_id.present? + end + + public + + # Calculate actual words written on a specific date (delta from previous day) + # Uses ActiveRecord for database-agnostic queries + def self.words_written_on_date(user, target_date) + # Get all records for the target date + today_records = where(user: user, for_date: target_date) + .select(:entity_type, :entity_id, :word_count) + .to_a + + return 0 if today_records.empty? + + # Defensive: Dedupe by entity (in case of duplicate records from race conditions) + # Keep the record with the highest word_count per entity + today_records = today_records + .group_by { |r| [r.entity_type, r.entity_id] } + .transform_values { |records| records.max_by(&:word_count) } + .values + + # Get the previous record for each entity in a single query using a subquery + entity_ids_by_type = today_records.group_by(&:entity_type).transform_values { |recs| recs.map(&:entity_id).uniq } + + prev_records = [] + entity_ids_by_type.each do |type, ids| + prev_records.concat( + where(user: user) + .where('for_date < ?', target_date) + .where(entity_type: type, entity_id: ids) + .group(:entity_type, :entity_id) + .select(:entity_type, :entity_id, 'MAX(for_date) as max_date') + .to_a + ) + end + + # Fetch the actual word counts for those dates in bulk (avoids N+1) + prev_word_counts = {} + + if prev_records.any? + # Build conditions for bulk fetch using OR clauses + conditions = prev_records.map do |pr| + sanitize_sql_array([ + "(entity_type = ? AND entity_id = ? AND for_date = ?)", + pr.entity_type, pr.entity_id, pr.max_date + ]) + end + + records = where(user: user) + .where(conditions.join(' OR ')) + .select(:entity_type, :entity_id, :word_count) + + records.each do |r| + prev_word_counts[[r.entity_type, r.entity_id]] = r.word_count + end + end + + # Calculate deltas + total_delta = 0 + today_records.each do |record| + prev_count = prev_word_counts[[record.entity_type, record.entity_id]] || 0 + delta = record.word_count - prev_count + if record.entity_type == 'ManualAdjustment' || delta > 0 + total_delta += delta + end + end + + total_delta + end + + # Batch calculate words written for multiple dates efficiently + # Returns a hash of { date => word_count } + # Uses a single query to fetch all data, then calculates deltas in Ruby + def self.batch_words_written_on_dates(user, dates) + return {} if dates.empty? + + dates = dates.sort + earliest_date = dates.first + latest_date = dates.last + + # Fetch all records in the date range plus records before earliest_date for baseline + # We need records from before earliest_date to calculate delta for the first date + all_records = where(user: user) + .where('for_date <= ?', latest_date) + .select(:entity_type, :entity_id, :word_count, :for_date) + .order(:entity_type, :entity_id, :for_date) + .to_a + + return dates.each_with_object({}) { |d, h| h[d] = 0 } if all_records.empty? + + # Group records by entity + records_by_entity = all_records.group_by { |r| [r.entity_type, r.entity_id] } + + # For each target date, calculate delta for each entity + result = {} + dates.each do |target_date| + total_delta = 0 + + records_by_entity.each do |_entity_key, entity_records| + # Find the record for target_date (if any) + today_record = entity_records.find { |r| r.for_date == target_date } + next unless today_record + + # Find the previous record (most recent before target_date) + prev_record = entity_records + .select { |r| r.for_date < target_date } + .max_by(&:for_date) + + prev_count = prev_record&.word_count || 0 + delta = today_record.word_count - prev_count + if today_record.entity_type == 'ManualAdjustment' || delta > 0 + total_delta += delta + end + end + + result[target_date] = total_delta + end + + result + end + + # Alias for backwards compatibility - calls the efficient batch method + def self.words_written_on_dates(user, dates) + batch_words_written_on_dates(user, dates) + end + + # Get all dates with writing activity in a range (single query) + # Returns a Set of dates where the user wrote words (positive delta) + def self.dates_with_writing_activity(user, start_date, end_date) + word_counts = batch_words_written_on_dates(user, (start_date..end_date).to_a) + word_counts.select { |_date, count| count > 0 }.keys.to_set + end + + # Calculate actual words written in a date range + # Compares the final word count in the range vs the day before the range started + def self.words_written_in_range(user, date_range) + start_date = date_range.first + end_date = date_range.last + + # Get the latest record for each entity within the date range + latest_in_range = where(user: user, for_date: date_range) + .group(:entity_type, :entity_id) + .select(:entity_type, :entity_id, 'MAX(for_date) as max_date') + .to_a + + return 0 if latest_in_range.empty? + + # Fetch the actual word counts for the latest dates in bulk (avoids N+1) + current_word_counts = {} + + if latest_in_range.any? + conditions = latest_in_range.map do |lr| + sanitize_sql_array([ + "(entity_type = ? AND entity_id = ? AND for_date = ?)", + lr.entity_type, lr.entity_id, lr.max_date + ]) + end + + records = where(user: user) + .where(conditions.join(' OR ')) + .select(:entity_type, :entity_id, :word_count) + + records.each do |r| + current_word_counts[[r.entity_type, r.entity_id]] = r.word_count + end + end + + # Get the previous record for each entity before the range started + entity_ids_by_type = latest_in_range.group_by(&:entity_type).transform_values { |recs| recs.map(&:entity_id).uniq } + + prev_records = [] + entity_ids_by_type.each do |type, ids| + prev_records.concat( + where(user: user) + .where('for_date < ?', start_date) + .where(entity_type: type, entity_id: ids) + .group(:entity_type, :entity_id) + .select(:entity_type, :entity_id, 'MAX(for_date) as max_date') + .to_a + ) + end + + # Fetch the actual word counts for those dates in bulk (avoids N+1) + prev_word_counts = {} + + if prev_records.any? + conditions = prev_records.map do |pr| + sanitize_sql_array([ + "(entity_type = ? AND entity_id = ? AND for_date = ?)", + pr.entity_type, pr.entity_id, pr.max_date + ]) + end + + records = where(user: user) + .where(conditions.join(' OR ')) + .select(:entity_type, :entity_id, :word_count) + + records.each do |r| + prev_word_counts[[r.entity_type, r.entity_id]] = r.word_count + end + end + + # Calculate deltas + total_delta = 0 + current_word_counts.keys.each do |key| + current = current_word_counts[key] || 0 + prev = prev_word_counts[key] || 0 + delta = current - prev + if key[0] == 'ManualAdjustment' || delta > 0 + total_delta += delta + end + end + + total_delta + end end diff --git a/app/models/writing_goal.rb b/app/models/writing_goal.rb new file mode 100644 index 000000000..60880696c --- /dev/null +++ b/app/models/writing_goal.rb @@ -0,0 +1,102 @@ +class WritingGoal < ApplicationRecord + belongs_to :user + + # Returns the current date in the user's configured timezone + def user_current_date + user&.time_zone.present? ? Time.current.in_time_zone(user.time_zone).to_date : Date.current + end + + validates :title, presence: true + validates :target_word_count, presence: true, numericality: { greater_than: 0 } + validates :start_date, presence: true + validates :end_date, presence: true + validate :end_date_after_start_date + + scope :active, -> { where(active: true) } + scope :completed, -> { where.not(completed_at: nil) } + scope :archived, -> { where(archived: true) } + scope :not_archived, -> { where(archived: false) } + scope :current, -> { active.not_archived.where('end_date >= ?', Date.current) } + + def days_remaining + return 0 if user_current_date > end_date + (end_date - user_current_date).to_i + end + + def total_days + (end_date - start_date).to_i + end + + def days_elapsed + return 0 if user_current_date < start_date + [total_days, (user_current_date - start_date).to_i].min + end + + def words_written_during_goal + WordCountUpdate.words_written_in_range(user, start_date..user_current_date) + end + + def words_remaining + [target_word_count - words_written_during_goal, 0].max + end + + def daily_goal + return 0 if days_remaining.zero? + (words_remaining.to_f / days_remaining).ceil + end + + def original_daily_goal + return 0 if total_days.zero? + (target_word_count.to_f / total_days).ceil + end + + def progress_percentage + return 100.0 if target_word_count.zero? + [(words_written_during_goal.to_f / target_word_count * 100), 100.0].min + end + + def expected_words_by_today + return target_word_count if user_current_date >= end_date + return 0 if user_current_date < start_date + (original_daily_goal * days_elapsed) + end + + def ahead_or_behind + words_written_during_goal - expected_words_by_today + end + + def on_track? + ahead_or_behind >= 0 + end + + def completed? + completed_at.present? || words_written_during_goal >= target_word_count + end + + def mark_completed! + update(completed_at: Time.current, active: false) if completed? + end + + def archive! + update(archived: true) + end + + def unarchive! + update(archived: false) + end + + def daily_word_counts + end_dt = [user_current_date, end_date].min + dates = (start_date..end_dt).to_a + WordCountUpdate.words_written_on_dates(user, dates) + end + + private + + def end_date_after_start_date + return unless start_date && end_date + if end_date <= start_date + errors.add(:end_date, 'must be after start date') + end + end +end diff --git a/app/services/README_stream_events.md b/app/services/README_stream_events.md new file mode 100644 index 000000000..8ba56affb --- /dev/null +++ b/app/services/README_stream_events.md @@ -0,0 +1,117 @@ +# Stream Events Integration + +This document explains how to easily create stream events from anywhere in the application using the `StreamEventService`. + +## Usage Examples + +### 1. Manual Page Sharing + +```ruby +# When a user manually shares a page +StreamEventService.create_share_event( + user: current_user, + content_page: @character, + message: "Check out my new character!" +) +``` + +### 2. Collection Publishing + +```ruby +# When a page gets published to a collection +StreamEventService.create_collection_published_event( + user: @page.user, + content_page: @page, + collection: @collection +) +``` + +### 3. Document Publishing + +```ruby +# When a user publishes a document +StreamEventService.create_document_published_event( + user: current_user, + document: @document +) +``` + +### 4. Generic Activity Events + +```ruby +# Generic method for various activity types +StreamEventService.create_activity_event( + user: current_user, + activity_type: :published_to_collection, + target: { + content_page: @page, + collection: @collection + } +) +``` + +## Integration Points + +### In Controllers + +Add stream events to existing controllers: + +```ruby +# In page_collections_controller.rb +def add_page_to_collection + # ... existing logic ... + + if @submission.approved? + StreamEventService.create_collection_published_event( + user: @submission.content_page.user, + content_page: @submission.content_page, + collection: @collection + ) + end +end +``` + +### In Jobs/Background Tasks + +```ruby +class PublishToCollectionJob < ApplicationJob + def perform(page_id, collection_id) + # ... existing logic ... + + StreamEventService.create_activity_event( + user: page.user, + activity_type: :published_to_collection, + target: { content_page: page, collection: collection } + ) + end +end +``` + +### In Models (after_save callbacks) + +```ruby +class Document < ApplicationRecord + after_update :create_stream_event_if_published + + private + + def create_stream_event_if_published + if saved_change_to_privacy? && privacy == 'public' + StreamEventService.create_document_published_event( + user: self.user, + document: self + ) + end + end +end +``` + +## Extending the System + +To add new event types: + +1. Add a new method to `StreamEventService` +2. Add a case to `create_activity_event` method +3. Optionally create new partial templates in `app/views/stream/` for custom event rendering + +The system is designed to be lightweight and extensible while maintaining consistency with the existing notification system. \ No newline at end of file diff --git a/app/services/changelog_stats_service.rb b/app/services/changelog_stats_service.rb new file mode 100644 index 000000000..b9d533d17 --- /dev/null +++ b/app/services/changelog_stats_service.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +class ChangelogStatsService + def initialize(content) + @content = content + @change_events = content.attribute_change_events + end + + def total_changes + @change_events.sum { |event| event.changed_fields.keys.length } + end + + def active_days + @change_events.map { |event| event.created_at.to_date }.uniq.length + end + + def most_recent_activity + @change_events.first&.created_at + end + + def creation_date + @content.created_at + end + + def days_since_creation + (Date.current - creation_date.to_date).to_i + end + + def most_active_field + field_counts = Hash.new(0) + + @change_events.each do |event| + event.changed_fields.keys.each do |field_key| + field_counts[field_key] += 1 + end + end + + return nil if field_counts.empty? + + most_frequent_field_id = field_counts.max_by { |_, count| count }.first + find_field_by_id(most_frequent_field_id) + end + + def change_intensity_by_week + # Group changes by week for the page's lifetime (capped at 52 weeks) + weeks_since_creation = (days_since_creation / 7.0).ceil + weeks_to_show = [weeks_since_creation + 1, 52].min + + weeks = [] + (0..(weeks_to_show - 1)).each do |i| + week_start = i.weeks.ago.beginning_of_week + week_end = week_start.end_of_week + + changes_this_week = @change_events.select do |event| + event.created_at >= week_start && event.created_at <= week_end + end + + weeks << { + week_start: week_start, + week_end: week_end, + change_count: changes_this_week.sum { |event| event.changed_fields.keys.length }, + event_count: changes_this_week.length + } + end + + weeks.reverse + end + + def changes_by_day_of_week + day_counts = Hash.new(0) + + @change_events.each do |event| + day_name = event.created_at.strftime('%A') + day_counts[day_name] += event.changed_fields.keys.length + end + + # Return in week order + %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].map do |day| + { day: day, count: day_counts[day] } + end + end + + def biggest_single_update + biggest_event = @change_events.max_by { |event| event.changed_fields.keys.length } + return nil unless biggest_event + + { + event: biggest_event, + field_count: biggest_event.changed_fields.keys.length, + date: biggest_event.created_at + } + end + + def writing_streaks + # Find consecutive days with changes + change_dates = @change_events.map { |event| event.created_at.to_date }.uniq.sort.reverse + + return [] if change_dates.empty? + + streaks = [] + current_streak = [change_dates.first] + + change_dates.each_cons(2) do |current_date, next_date| + if (current_date - next_date).to_i == 1 + current_streak << next_date + else + streaks << current_streak if current_streak.length > 1 + current_streak = [next_date] + end + end + + streaks << current_streak if current_streak.length > 1 + streaks.sort_by(&:length).reverse + end + + def longest_writing_streak + streaks = writing_streaks + return nil if streaks.empty? + + longest = streaks.first + { + length: longest.length, + start_date: longest.last, + end_date: longest.first + } + end + + def grouped_changes_by_date + # Group changes by date for timeline display + grouped = @change_events.group_by { |event| event.created_at.to_date } + + grouped.map do |date, events| + { + date: date, + events: events, + total_field_changes: events.sum { |event| event.changed_fields.keys.length }, + users: events.map(&:user).compact.uniq + } + end.sort_by { |group| group[:date] }.reverse + end + + private + + def find_field_by_id(field_id) + # Get related attribute and field information + related_attribute = Attribute.find_by(id: @change_events.map(&:content_id).uniq) + return nil unless related_attribute + + AttributeField.find_by(id: related_attribute.attribute_field_id) + end +end \ No newline at end of file diff --git a/app/services/content_formatter_service.rb b/app/services/content_formatter_service.rb index e1eb40d29..e01889b18 100644 --- a/app/services/content_formatter_service.rb +++ b/app/services/content_formatter_service.rb @@ -174,7 +174,7 @@ def self.link_for(content_model) [ Rails.env.production? ? 'https://' : 'http://', Rails.env.production? ? 'www.notebook.ai' : 'localhost:3000', # Rails.application.routes.default_url_options[:host]? - content_model.class.name != Document.name ? '/plan/' : '/', + %w[Document Book].exclude?(content_model.class.name) ? '/plan/' : '/', content_model.class.name.downcase.pluralize, '/', content_model.id diff --git a/app/services/documents/analysis/content_service.rb b/app/services/documents/analysis/content_service.rb index d21f39924..560f1e029 100644 --- a/app/services/documents/analysis/content_service.rb +++ b/app/services/documents/analysis/content_service.rb @@ -22,7 +22,7 @@ def self.analyze(analysis_id) analysis.save! end - def self.adult_content?(matchlist=:hate, content) + def self.adult_content?(content, matchlist: :hate) LanguageFilter::Filter.new(matchlist: matchlist.to_sym).matched(content) end end diff --git a/app/services/documents/analysis/counting_service.rb b/app/services/documents/analysis/counting_service.rb index 26b94b60f..8454858b8 100644 --- a/app/services/documents/analysis/counting_service.rb +++ b/app/services/documents/analysis/counting_service.rb @@ -8,7 +8,8 @@ def self.analyze(analysis_id) document = analysis.document # Length counters - analysis.word_count = document.words.count + # Use WordCountService for consistent word counting across the app + analysis.word_count = WordCountService.count(document.body) analysis.page_count = document.pages.count analysis.paragraph_count = document.paragraphs.count analysis.character_count = document.characters.count diff --git a/app/services/documents/analysis/flesch_kincaid_service.rb b/app/services/documents/analysis/flesch_kincaid_service.rb index b07283b5e..d557d52d7 100644 --- a/app/services/documents/analysis/flesch_kincaid_service.rb +++ b/app/services/documents/analysis/flesch_kincaid_service.rb @@ -2,7 +2,8 @@ module Documents module Analysis class FleschKincaidService < Service def self.grade_level(document) - @flesch_kincaid_grade_level ||= [ + return 0 if document.words.length == 0 || document.sentences.length == 0 + [ 0.38 * (document.words.length.to_f / document.sentences.length), 11.18 * (document.word_syllables.sum.to_f / document.words.length), -15.59 @@ -10,7 +11,7 @@ def self.grade_level(document) end def self.age_minimum(document) - @flesch_kincaid_age_minimum ||= case reading_ease(document) + case reading_ease(document) when (90..100) then 11 when (71..89) then 12 when (67..69) then 13 @@ -26,7 +27,8 @@ def self.age_minimum(document) end def self.reading_ease(document) - @flesch_kincaid_reading_ease ||= [ + return 0 if document.words.length == 0 || document.sentences.length == 0 + [ 206.835, -(1.015 * document.words.length.to_f / document.sentences.length), -(84.6 * document.word_syllables.sum.to_f / document.words.length) diff --git a/app/services/documents/analysis/readability_service.rb b/app/services/documents/analysis/readability_service.rb index 9c5414dbb..5081ee39c 100644 --- a/app/services/documents/analysis/readability_service.rb +++ b/app/services/documents/analysis/readability_service.rb @@ -26,11 +26,13 @@ def self.analyze(analysis_id) end def self.forcast_grade_level(document) - @forcast_grade_level ||= 20 - (((document.words_with_syllables(1).length.to_f / document.words.length) * 150) / 10.0).clamp(1, 16) + return 1 if document.words.length == 0 + (20 - (((document.words_with_syllables(1).length.to_f / document.words.length) * 150) / 10.0)).clamp(1, 16) end def self.coleman_liau_index(document) - @coleman_liau_index ||= [ + return 1 if document.words.length == 0 || document.sentences.length == 0 + [ 0.0588 * 100 * document.characters.reject { |l| [" ", "\t", "\r", "\n"].include?(l) }.length.to_f / document.words.length, -0.296 * 100/ (document.words.length.to_f / document.sentences.length), -15.8 @@ -38,7 +40,8 @@ def self.coleman_liau_index(document) end def self.automated_readability_index(document) - @automated_readability_index ||= [ + return 1 if document.words.length == 0 || document.sentences.length == 0 + [ 4.71 * document.characters.reject(&:blank?).length.to_f / document.words.length, 0.5 * document.words.length.to_f / document.sentences.length, -21.43 @@ -47,19 +50,21 @@ def self.automated_readability_index(document) def self.gunning_fog_index(document) #todo GFI word/suffix exclusions - @gunning_fog_index ||= 0.4 * (document.words.length.to_f/ document.sentences.length + 100 * (document.complex_words.length.to_f / document.words.length)).clamp(1, 16) + return 1 if document.words.length == 0 || document.sentences.length == 0 + (0.4 * (document.words.length.to_f/ document.sentences.length + 100 * (document.complex_words.length.to_f / document.words.length))).clamp(1, 16) end def self.smog_grade(document) - @smog_grade ||= (1.043 * Math.sqrt(document.complex_words.length.to_f * (30.0 / document.sentences.length)) + 3.1291).clamp(1, 16) + return 1 if document.sentences.length == 0 + (1.043 * Math.sqrt(document.complex_words.length.to_f * (30.0 / document.sentences.length)) + 3.1291).clamp(1, 16) end def self.linsear_write_grade(document) - @linsear_write_grade ||= TextStat.linsear_write_formula(document.plaintext).clamp(1, 16) + TextStat.linsear_write_formula(document.plaintext).clamp(1, 16) end def self.dale_chall_grade(document) - @dale_chall_grade ||= TextStat.dale_chall_readability_score(document.plaintext).clamp(1, 10) + TextStat.dale_chall_readability_score(document.plaintext).clamp(1, 10) end # deprecated in favor of TextStat.text_standard(text, float_output=False) diff --git a/app/services/documents/analysis/syllables_service.rb b/app/services/documents/analysis/syllables_service.rb index 9baf5759b..e55df663d 100644 --- a/app/services/documents/analysis/syllables_service.rb +++ b/app/services/documents/analysis/syllables_service.rb @@ -6,13 +6,14 @@ class SyllablesService < Service } def self.count(word) - word.downcase.gsub!(/[^a-z]/, '') + word = word.downcase.gsub(/[^a-z]/, '') return 1 if word.length <= 3 return SYLLABLE_COUNT_OVERRIDES[word] if SYLLABLE_COUNT_OVERRIDES.key?(word) - word.sub(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').sub!(/^y/, '') - word.scan(/[aeiouy]{1,2}/).length + word = word.sub(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').sub(/^y/, '') + count = word.scan(/[aeiouy]{1,2}/).length + count > 0 ? count : 1 end end end diff --git a/app/services/forum_replacement_service.rb b/app/services/forum_replacement_service.rb index 133802d98..e8eac4ad0 100644 --- a/app/services/forum_replacement_service.rb +++ b/app/services/forum_replacement_service.rb @@ -36,7 +36,7 @@ class ForumReplacementService < Service # gremlin replacements GREMLINS_WORD_REPLACEMENTS = { - '<3' => "&heart;", + '<3' => "&heart;", '$5' => '[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]', '0 Kelvin' => 'absolute frosty', '0/10' => '10/10', @@ -335,7 +335,7 @@ class ForumReplacementService < Service 'shrimp' => 'chill krill', 'shrimps' => 'chill krills', 'skydiving' => 'falling out of the sky', - 'smol' => 'smol', + 'smol' => 'smol', 'snake' => 'slippery dippery long mover', 'snakes' => 'slippery dippery long movers', 'snail' => 'slime racer', @@ -393,7 +393,7 @@ class ForumReplacementService < Service 'whales' => 'blubberbutt watermutts', 'wheeze' => 'sneeze', 'wheezing' => 'sneezing', - 'why is everyone yelling' => 'why is everyone yelling', + 'why is everyone yelling' => 'why is everyone yelling', 'window' => 'see-through wall', 'worm' => 'wriggly wiggly', "writer's block" => 'imagination traffic jam', @@ -406,7 +406,7 @@ class ForumReplacementService < Service 'years' => 'orbit parties', 'yeet' => 'defenestrate', 'yoga' => 'bendy business', - 'yuge' => 'yuge', + 'yuge' => 'yuge', 'zalgo' => 'H̶̛̼̼̪̝̞͓̞͕͇̯͚͎͚̘̳͕̱̤̠̗͔͇̙̣̰͓̖̰̯̀̓̐̑̇͊͂̀͋̒̐̓͒̒͊͊̕͜͝ͅE̴̡̧̨̨̲̥̯͎̭̻̩̞̘̞̪̞̗̭͖̻͙͕͎̮͕̺͕̲̘̻̣͚̳̥͍̙͈͚͍͉̗͙̱͖͚̾̂̇͛̉͋͊̾͛̆̀́͑͛̅̋͊̕͘͜͜͜͝ͅͅͅͅ ̸̡̡̨̡̨̛̞͎̹̩̬̗̗̞̬̰̮̙̪̖͈̣̹͔̺̫̰̓̔̉̋̈̈́͐́̿̈̀͊̿̈̉̅̃̊̽͗̈̿̈́̓̈́̎͌̄̀̆̌̎͗̋͒̋̿̋̊̈́͆̋̾̈̏̈́̋̿̕̕̚͝͝͠͠ͅͅͅC̵̛̘̳͙̪̭͖̲̞̯̰̜͇̈̾̈́͋̌̉̽̽͑̎͌̾̈́͌̑͊̊̔̀͆̌̀̇̓͊̀̂̇̿̃͑́̈́̆͂̈́̾̓́̂̂̓̂̍̍͛͆͌͌̽̎̍̀̒̆̀͗͋͘͘͘͝͠͝͝͠͝͝Ǫ̸͕̻̞̝̜͚̗̮̼͎̤͔̤̱͔̫͂̄̉̋̈͊͐͂̇̀̌̎́͑̐̀̈́͋̓̾̅͒̒̄͑̒̆̑̾͜͝͝͝͝M̷̧̧̡̨̛̛̩̭̞͍̼̝̗͕̖͇̣̣̩͆̿̑͒́̉̅̓̌̆̈́͐͒̾̐̂̿̓̚͘̚͜E̵̡̨̢̧̢̢̡̢̨̛̠̱̻̺̦͚̹͓̬͔̪̟̼̥̯̠̘͚̫̯͍̺͔̫̟͇̱̦̟̪͚͉̣̳͓͍̬̙̲͔̘͙͔̤̰̜͍̠̩͉͐̂̊̏̐̿̊̋͑̿̇̊̈́͗̎̋́́̉̓̂̐͑̇̐̐͋́̒̈́͛͑͒̂͒̂̔̀̄̈́̓͂͆̈́͒̌͆̓͗̋͐̔̑͐̕͘ͅͅͅŞ̴̧̧̡̢̧̡̢͕̝͚̝̖͚̣̞̫̻̯͔̳̗̝̰̗̰̰̥̭͕̜̜̫͍̪̳̘̣̺̠͉̗̟͕̹͇̬̘̘̪͆͗̎̕', 'antagonist' => 'plot troublemaker', 'character arc' => 'protagonist pilgrimage', @@ -577,7 +577,7 @@ class ForumReplacementService < Service } def self.replace_for(text, user) - gremlins_phase = 0 + gremlins_phase = 3 replaced_text = text.dup # Page tag replacements @@ -635,6 +635,7 @@ def self.replace_for(text, user) end def self.wrapped(text, tooltip, color='blue') - "#{text}" + # Flat, simple, soft highlight reflecting the site aesthetic (avoiding malware link vibes) + "#{text}" end end diff --git a/app/services/green_service.rb b/app/services/green_service.rb index ccf9fd181..c5b4a6abb 100644 --- a/app/services/green_service.rb +++ b/app/services/green_service.rb @@ -49,31 +49,76 @@ def self.trees_saved_by(worldbuilding_page_type) end def self.total_document_pages_equivalent - total_pages = 0 + # Use a single query with conditional aggregation instead of 3 separate queries + result = Document.with_deleted + .where.not(cached_word_count: nil) + .select( + 'COUNT(CASE WHEN cached_word_count <= 500 THEN 1 END) as small_docs_count', + 'SUM(CASE WHEN cached_word_count > 500 THEN cached_word_count ELSE 0 END) as large_docs_word_count', + 'COUNT(CASE WHEN cached_word_count > 500 THEN 1 END) as large_docs_count' + ).take + + small_docs_count = result.small_docs_count || 0 + large_docs_word_count = result.large_docs_word_count || 0 + large_docs_count = result.large_docs_count || 0 # Treat all <1-page documents as 1 page per document, since they'd print on separate pages - total_pages += Document.with_deleted.where('cached_word_count <= ?', AVERAGE_WORDS_PER_PAGE).count + total_pages = small_docs_count # For all >1-page documents, do a quick estimate of word count sum + num docs to also cover EOD page breaks - docs = Document.with_deleted.where.not(cached_word_count: nil).where('cached_word_count > ?', AVERAGE_WORDS_PER_PAGE) - total_pages += (docs.sum(:cached_word_count) / AVERAGE_WORDS_PER_PAGE.to_f).round - total_pages += docs.count + total_pages += (large_docs_word_count / AVERAGE_WORDS_PER_PAGE.to_f).round + total_pages += large_docs_count total_pages end - def self.total_timeline_pages_equivalent - ((TimelineEvent.last.try(:id) || 0) / AVERAGE_TIMELINE_EVENTS_PER_PAGE.to_f).to_i + def self.total_timeline_pages_equivalent(max_timeline_event_id = nil) + max_id = max_timeline_event_id || (TimelineEvent.last.try(:id) || 0) + (max_id / AVERAGE_TIMELINE_EVENTS_PER_PAGE.to_f).to_i end - def self.total_physical_pages_equivalent(content_type) + def self.total_physical_pages_equivalent(content_type, max_id: nil) case content_type.name when 'Timeline' - GreenService.total_timeline_pages_equivalent + GreenService.total_timeline_pages_equivalent(max_id) when 'Document' GreenService.total_document_pages_equivalent else - GreenService.physical_pages_equivalent_for(content_type.name) * (content_type.last.try(:id) || 0) + fetched_max_id = max_id || (content_type.last.try(:id) || 0) + GreenService.physical_pages_equivalent_for(content_type.name) * fetched_max_id + end + end + + # Batch-fetch max IDs for all content types in a single query + # Returns a hash mapping content_type.name => max_id + def self.max_ids_for_content_types(content_types) + return {} if content_types.empty? + + queries = content_types.map do |content_type| + case content_type.name + when 'Timeline' + # Timeline uses TimelineEvent's max ID from timeline_events table + "SELECT 'TimelineEvent' as content_type, COALESCE(MAX(id), 0) as max_id FROM timeline_events" + when 'Document' + # Document table - include all records for total count + "SELECT 'Document' as content_type, COALESCE(MAX(id), 0) as max_id FROM documents" + else + # Standard content types use pluralized lowercase table names + table_name = content_type.name.downcase.pluralize + "SELECT '#{content_type.name}' as content_type, COALESCE(MAX(id), 0) as max_id FROM #{table_name}" + end + end + + sql = queries.join(' UNION ALL ') + results = ActiveRecord::Base.connection.execute(sql) + + # Convert to hash, handling both SQLite (hash) and MySQL (array) result formats + results.to_a.each_with_object({}) do |row, hash| + if row.is_a?(Hash) + hash[row['content_type']] = row['max_id'].to_i + else + hash[row[0]] = row[1].to_i + end end end diff --git a/app/services/serendipitous_service.rb b/app/services/serendipitous_service.rb index eb77ea37f..b0b154b6a 100644 --- a/app/services/serendipitous_service.rb +++ b/app/services/serendipitous_service.rb @@ -8,14 +8,6 @@ def self.question_for(content) hidden: [nil, false] ) - # TODO: we should remove this at some point. How do we know when it's safe to do so? - # TODO: is this what creates new fields/categories for new users? hopefully not. - if categories_for_this_type.empty? && content.present? - # If this page type has no categories, it needs migrated to the new attribute system - TemporaryFieldMigrationService.migrate_fields_for_content(content, content.user) - end - #raise categories_for_this_type.pluck(:label).inspect - fields_for_these_categories = AttributeField.where( user: content.user, field_type: "text_area", diff --git a/app/services/stream_event_service.rb b/app/services/stream_event_service.rb new file mode 100644 index 000000000..3c268f29b --- /dev/null +++ b/app/services/stream_event_service.rb @@ -0,0 +1,66 @@ +class StreamEventService + def self.create_share_event(user:, content_page:, message: nil) + return unless user && content_page + + # Make the content page public when sharing + content_page.update(privacy: 'public') if content_page.respond_to?(:privacy) + + ContentPageShare.create!( + user: user, + content_page: content_page, + message: message, + shared_at: DateTime.current + ) + end + + def self.create_collection_published_event(user:, content_page:, collection:) + return unless user && content_page && collection + + message = "#{content_page.name} was featured in the collection #{collection.name}!" + + create_share_event( + user: user, + content_page: content_page, + message: message + ) + end + + def self.create_forum_thread_event(user:, thread_title:, thread_url:) + # For forum threads, we'll need to create a different type of stream event + # This would require extending the ContentPageShare model or creating a new model + # For now, this is a placeholder for future implementation + Rails.logger.info "StreamEventService: Would create forum thread event for #{user.display_name}: #{thread_title}" + end + + def self.create_document_published_event(user:, document:) + return unless user && document + + create_share_event( + user: user, + content_page: document, + message: "Just published this document!" + ) + end + + # Helper method to create notification-style stream events + def self.create_activity_event(user:, activity_type:, target:, message: nil) + case activity_type + when :published_to_collection + create_collection_published_event( + user: user, + content_page: target[:content_page], + collection: target[:collection] + ) + when :shared_document + create_document_published_event(user: user, document: target) + when :forum_thread + create_forum_thread_event( + user: user, + thread_title: target[:title], + thread_url: target[:url] + ) + else + Rails.logger.warn "StreamEventService: Unknown activity type: #{activity_type}" + end + end +end \ No newline at end of file diff --git a/app/services/template_export_service.rb b/app/services/template_export_service.rb new file mode 100644 index 000000000..757ea7f67 --- /dev/null +++ b/app/services/template_export_service.rb @@ -0,0 +1,281 @@ +class TemplateExportService + def initialize(user, content_type) + @user = user + @content_type = content_type.downcase + @content_type_class = @content_type.titleize.constantize + @categories = load_template_structure + end + + def export_as_yaml + template_data = build_template_data + + # Generate YAML with comments and metadata + yaml_content = [] + yaml_content << "# #{@content_type.titleize} Template Export" + yaml_content << "# Generated: #{Time.current.strftime('%Y-%m-%d %H:%M:%S UTC')}" + yaml_content << "# Content Type: #{@content_type.titleize}" + yaml_content << "# Categories: #{template_data[:statistics][:total_categories]} | Fields: #{template_data[:statistics][:total_fields]} | User: #{@user.username || @user.email}" + yaml_content << "" + yaml_content << template_data.to_yaml + + yaml_content.join("\n") + end + + def export_as_markdown + template_data = build_template_data + + markdown_content = [] + markdown_content << "# #{@content_type.titleize} Template" + markdown_content << "" + markdown_content << "**Generated:** #{Time.current.strftime('%B %d, %Y at %H:%M UTC')}" + markdown_content << "**Content Type:** #{@content_type.titleize}" + markdown_content << "**Categories:** #{template_data[:statistics][:total_categories]} | **Fields:** #{template_data[:statistics][:total_fields]}" + markdown_content << "" + + # Template overview + markdown_content << "## Template Overview" + markdown_content << "" + markdown_content << "This template defines the structure and fields for your #{@content_type.titleize.downcase} pages." + markdown_content << "" + + # Statistics + stats = template_data[:statistics] + markdown_content << "### Statistics" + markdown_content << "" + markdown_content << "- **Total Categories:** #{stats[:total_categories]}" + markdown_content << "- **Total Fields:** #{stats[:total_fields]}" + markdown_content << "- **Hidden Categories:** #{stats[:hidden_categories]}" + markdown_content << "- **Hidden Fields:** #{stats[:hidden_fields]}" + markdown_content << "- **Custom Categories:** #{stats[:custom_categories]}" + markdown_content << "" + + # Categories and fields + markdown_content << "## Template Structure" + markdown_content << "" + + template_data[:template][:categories].each do |category_name, category_data| + icon_display = category_data[:icon] ? " 📋" : "" + hidden_display = category_data[:hidden] ? " (Hidden)" : "" + + markdown_content << "### #{category_data[:label]}#{icon_display}#{hidden_display}" + markdown_content << "" + + if category_data[:description].present? + markdown_content << "_#{category_data[:description]}_" + markdown_content << "" + end + + if category_data[:fields].any? + category_data[:fields].each do |field_name, field_data| + field_icon = case field_data[:field_type] + when 'name' then '📝' + when 'text_area' then '📄' + when 'link' then '🔗' + when 'universe' then '🌍' + when 'tags' then '🏷️' + else '📋' + end + + hidden_text = field_data[:hidden] ? " _(Hidden)_" : "" + markdown_content << "- **#{field_data[:label]}** #{field_icon}#{hidden_text}" + + if field_data[:description].present? + markdown_content << " - _#{field_data[:description]}_" + end + + if field_data[:field_type] == 'link' && field_data[:field_options][:linkable_types].present? + linkable_types = field_data[:field_options][:linkable_types].join(', ') + markdown_content << " - Links to: #{linkable_types}" + end + end + markdown_content << "" + else + markdown_content << "_No fields in this category_" + markdown_content << "" + end + end + + # Customizations + if template_data[:customizations].any? + markdown_content << "## Template Customizations" + markdown_content << "" + markdown_content << "The following customizations have been made from the default template:" + markdown_content << "" + + template_data[:customizations].each do |customization| + case customization[:action] + when 'added_category' + markdown_content << "- ➕ **Added Category:** #{customization[:label]}" + when 'modified_field' + markdown_content << "- ✏️ **Modified Field:** #{customization[:category]} → #{customization[:field]} (#{customization[:change]})" + when 'hidden_category' + markdown_content << "- 👁️ **Hidden Category:** #{customization[:label]}" + when 'hidden_field' + markdown_content << "- 👁️ **Hidden Field:** #{customization[:category]} → #{customization[:field]}" + end + end + markdown_content << "" + end + + markdown_content << "---" + markdown_content << "_Template exported from Notebook.ai on #{Time.current.strftime('%B %d, %Y')}_" + + markdown_content.join("\n") + end + + def export_as_json + template_data = build_template_data + JSON.pretty_generate(template_data) + end + + def export_as_csv + require 'csv' + template_data = build_template_data + + CSV.generate(headers: true) do |csv| + # Header row + csv << [ + 'Category', 'Category_Position', 'Category_Hidden', 'Category_Description', + 'Field', 'Field_Type', 'Field_Position', 'Field_Hidden', 'Field_Description', + 'Field_Options' + ] + + # Data rows + template_data[:template][:categories].each do |category_name, category_data| + if category_data[:fields].any? + category_data[:fields].each do |field_name, field_data| + csv << [ + category_data[:label], + category_data[:position], + category_data[:hidden], + category_data[:description], + field_data[:label], + field_data[:field_type], + field_data[:position], + field_data[:hidden], + field_data[:description], + field_data[:field_options].to_json + ] + end + else + # Category with no fields + csv << [ + category_data[:label], + category_data[:position], + category_data[:hidden], + category_data[:description], + '', '', '', '', '', '' + ] + end + end + end + end + + private + + def load_template_structure + @content_type_class + .attribute_categories(@user, show_hidden: true) + .shown_on_template_editor + .includes(:attribute_fields) + .order(:position) + end + + def build_template_data + template_categories = {} + total_fields = 0 + hidden_categories = 0 + hidden_fields = 0 + custom_categories = 0 + customizations = [] + + # Load default template structure for comparison + default_structure = load_default_template_structure + + @categories.each do |category| + total_fields += category.attribute_fields.count + hidden_categories += 1 if category.hidden? + + # Check if this is a custom category (not in defaults) + unless default_structure.key?(category.name.to_sym) + custom_categories += 1 + customizations << { + action: 'added_category', + name: category.name, + label: category.label + } + end + + # Track hidden categories + if category.hidden? + customizations << { + action: 'hidden_category', + name: category.name, + label: category.label + } + end + + # Build category data + category_fields = {} + category.attribute_fields.order(:position).each do |field| + hidden_fields += 1 if field.hidden? + + # Track hidden fields + if field.hidden? + customizations << { + action: 'hidden_field', + category: category.label, + field: field.label + } + end + + category_fields[field.name.to_sym] = { + label: field.label, + field_type: field.field_type, + position: field.position, + description: field.description, + hidden: field.hidden?, + field_options: field.field_options || {} + } + end + + template_categories[category.name.to_sym] = { + label: category.label, + icon: category.icon, + description: category.description, + position: category.position, + hidden: category.hidden?, + fields: category_fields + } + end + + { + template: { + content_type: @content_type, + icon: @content_type_class.icon, + categories: template_categories + }, + statistics: { + total_categories: @categories.count, + total_fields: total_fields, + hidden_categories: hidden_categories, + hidden_fields: hidden_fields, + custom_categories: custom_categories + }, + customizations: customizations + } + end + + def load_default_template_structure + # Load the default YAML structure for comparison + yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml") + if File.exist?(yaml_path) + YAML.load_file(yaml_path) || {} + else + {} + end + rescue => e + Rails.logger.warn "Could not load default template structure for #{@content_type}: #{e.message}" + {} + end +end \ No newline at end of file diff --git a/app/services/template_initialization_service.rb b/app/services/template_initialization_service.rb new file mode 100644 index 000000000..bcc8cd2e0 --- /dev/null +++ b/app/services/template_initialization_service.rb @@ -0,0 +1,202 @@ +class TemplateInitializationService + def initialize(user, content_type) + @user = user + @content_type = content_type.downcase + @content_type_class = @content_type.titleize.constantize + end + + def initialize_default_template! + # Load the YAML template structure + yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml") + unless File.exist?(yaml_path) + Rails.logger.warn "No default template found for #{@content_type} at #{yaml_path}" + return [] + end + + template_structure = YAML.load_file(yaml_path) || {} + created_categories = [] + + ActiveRecord::Base.transaction do + # Create categories in YAML order, acts_as_list will handle positioning + template_structure.each do |category_name, details| + # Create category (ignoring soft-deleted ones) + category = create_category(category_name, details) + created_categories << category + + # Create fields for this category in YAML order + if details[:attributes].present? + details[:attributes].each do |field_details| + create_field(category, field_details) + end + end + end + end + + # Clear any cached template data + Rails.cache.delete("#{@content_type}_template_#{@user.id}") + + # Force correct ordering based on YAML file structure + if created_categories.any? + created_categories.first.backfill_categories_ordering! + end + + created_categories + end + + def recreate_template_after_reset! + # This method is specifically for template resets + # It assumes existing data has been cleaned up and we need fresh defaults + + Rails.logger.info "Recreating default template for user #{@user.id}, content_type #{@content_type}" + result = initialize_default_template! + + # Force reload of the content type's cached categories + if @content_type_class.respond_to?(:clear_attribute_cache) + @content_type_class.clear_attribute_cache(@user) + end + + result + end + + def template_exists? + # Check if user has any non-deleted template structure for this content type + @user.attribute_categories + .where(entity_type: @content_type) + .exists? + end + + def default_template_structure + # Load and return the YAML structure without creating database records + yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml") + return {} unless File.exist?(yaml_path) + + YAML.load_file(yaml_path) || {} + rescue => e + Rails.logger.error "Error loading default template structure: #{e.message}" + {} + end + + private + + def create_category(category_name, details) + # Only look for non-deleted categories + category = @user.attribute_categories + .where(entity_type: @content_type, name: category_name.to_s) + .first + + if category.nil? + # Let acts_as_list handle the positioning by creating at the end + category = @user.attribute_categories.create!( + entity_type: @content_type, + name: category_name.to_s, + label: details[:label] || category_name.to_s.titleize, + icon: details[:icon] || 'help' + ) + Rails.logger.debug "Created category: #{category.label} for #{@content_type} at position #{category.position}" + else + # Update existing category with current defaults (in case YAML changed) + category.update!( + label: details[:label] || category_name.to_s.titleize, + icon: details[:icon] || 'help' + ) + Rails.logger.debug "Updated existing category: #{category.label} for #{@content_type}" + end + + category + end + + def create_field(category, field_details) + field_name = field_details[:name] + field_type = field_details[:field_type].presence || "text_area" + field_label = field_details[:label].presence || field_name.to_s.titleize + + # Determine field_options for link fields + field_options = {} + if field_type == 'link' + linkable_types = determine_linkable_types_for_field(field_name) + field_options = { linkable_types: linkable_types } if linkable_types.any? + Rails.logger.debug "Setting linkable_types for #{field_label}: #{linkable_types.inspect}" + end + + # Only look for non-deleted fields + field = category.attribute_fields + .where(old_column_source: field_name) + .first + + if field.nil? + # Let acts_as_list handle positioning for fields too + field = category.attribute_fields.create!( + old_column_source: field_name, + user: @user, + field_type: field_type, + label: field_label, + field_options: field_options, + migrated_from_legacy: true + ) + Rails.logger.debug "Created field: #{field.label} in category #{category.label}" + else + # Update existing field with current defaults + field.update!( + field_type: field_type, + label: field_label, + field_options: field_options, + migrated_from_legacy: true + ) + Rails.logger.debug "Updated existing field: #{field.label} in category #{category.label}" + end + + field + end + + private + + def determine_linkable_types_for_field(field_name) + # Get the content class we're working with + content_class = @content_type.classify.constantize + + # Check if this field has a relationship defined + content_relations = Rails.application.config.content_relations || {} + content_type_key = @content_type.to_sym + + if content_relations[content_type_key] && content_relations[content_type_key][field_name.to_sym] + relation_info = content_relations[content_type_key][field_name.to_sym] + + # Get the relationship model class + relationship_class_name = relation_info[:relationship_class] + if relationship_class_name + begin + relationship_class = relationship_class_name.constantize + + # Find the belongs_to association that isn't the source + source_association_name = relation_info[:source_key]&.to_s&.singularize + + relationship_class.reflect_on_all_associations(:belongs_to).each do |assoc| + # Skip the association back to the source model + next if assoc.name.to_s == source_association_name + + # This should be the target association + target_class = assoc.klass + return [target_class.name] if target_class + end + rescue => e + Rails.logger.warn "Error determining linkable types for #{field_name}: #{e.message}" + end + end + end + + # Fallback: try to infer from common field naming patterns + case field_name.to_s + when /owner|character|person|friend|sibling|parent|child|relative/ + ['Character'] + when /location|place|town|city|building/ + ['Location'] + when /item|object|thing|equipment|weapon|tool/ + ['Item'] + when /universe|world|setting/ + ['Universe'] + else + # Default to allowing links to all major content types + ['Character', 'Location', 'Item'] + end + end +end \ No newline at end of file diff --git a/app/services/template_reset_service.rb b/app/services/template_reset_service.rb new file mode 100644 index 000000000..c23a7302d --- /dev/null +++ b/app/services/template_reset_service.rb @@ -0,0 +1,169 @@ +class TemplateResetService + require 'set' + + def initialize(user, content_type) + @user = user + @content_type = content_type.downcase + @content_type_class = @content_type.titleize.constantize + end + + def reset_template! + reset_summary = analyze_reset_impact + + ActiveRecord::Base.transaction do + # Soft delete all existing categories and fields for this content type + existing_categories = @user.attribute_categories + .where(entity_type: @content_type) + .includes(:attribute_fields) + + # Store counts for summary + reset_summary[:deleted_categories] = existing_categories.count + reset_summary[:deleted_fields] = existing_categories.sum { |cat| cat.attribute_fields.count } + + # Soft delete all fields first (to maintain referential integrity) + existing_categories.each do |category| + category.attribute_fields.destroy_all + end + + # Then soft delete all categories + existing_categories.destroy_all + + # Now recreate the default template structure + template_service = TemplateInitializationService.new(@user, @content_type) + created_categories = template_service.recreate_template_after_reset! + + # Store creation counts for summary + reset_summary[:created_categories] = created_categories.count + reset_summary[:created_fields] = created_categories.sum { |cat| cat.attribute_fields.count } + + Rails.logger.info "Template reset completed for user #{@user.id}, content_type #{@content_type}. " \ + "Deleted: #{reset_summary[:deleted_categories]} categories, #{reset_summary[:deleted_fields]} fields. " \ + "Created: #{reset_summary[:created_categories]} categories, #{reset_summary[:created_fields]} fields." + end + + reset_summary[:success] = true + reset_summary[:message] = "Template has been reset to defaults successfully! " \ + "Removed #{reset_summary[:deleted_categories]} custom categories and #{reset_summary[:deleted_fields]} custom fields. " \ + "Recreated #{reset_summary[:created_categories]} default categories with #{reset_summary[:created_fields]} default fields." + reset_summary + rescue => e + Rails.logger.error "Template reset failed for user #{@user.id}, content_type #{@content_type}: #{e.message}" + reset_summary.merge({ + success: false, + error: e.message, + created_categories: 0, + created_fields: 0 + }) + end + + def analyze_reset_impact + # Get current template structure to show what will be reset + current_categories = @user.attribute_categories + .where(entity_type: @content_type) + .includes(attribute_fields: :attribute_values) + .order(:position) + + custom_categories = [] + modified_fields = [] + data_loss_warnings = [] + filled_attributes_count = 0 + affected_pages_count = 0 + affected_pages = Set.new + + # Load default template to compare against + default_structure = load_default_template_structure + + current_categories.each do |category| + category_info = { + id: category.id, + name: category.name, + label: category.label, + icon: category.icon, + custom: !default_structure.key?(category.name.to_sym), + hidden: category.hidden?, + field_count: category.attribute_fields.count + } + + # Check if category is custom (not in defaults) + if !default_structure.key?(category.name.to_sym) + custom_categories << category_info + end + + # Check for modified fields and analyze attribute data + category.attribute_fields.each do |field| + # Count filled attribute values (non-empty, non-nil) + filled_values = field.attribute_values.where( + "value IS NOT NULL AND value != ''" + ) + + filled_count = filled_values.count + if filled_count > 0 + # Track unique pages that have data in this field + filled_values.each do |attr| + affected_pages.add("#{attr.entity_type}:#{attr.entity_id}") + end + + filled_attributes_count += filled_count + + data_loss_warnings << { + category: category.label, + field: field.label, + value_count: filled_count, + filled_count: filled_count # For backward compatibility + } + end + + # Check if field is custom or modified + default_category = default_structure[category.name.to_sym] + if default_category + default_field = default_category[:attributes]&.find { |f| f[:name].to_s == field.name } + if !default_field + modified_fields << { + category: category.label, + field: field.label, + type: 'custom_field' + } + end + end + end + end + + affected_pages_count = affected_pages.size + + # Load the default template to show what will be recreated + template_service = TemplateInitializationService.new(@user, @content_type) + default_structure = template_service.default_template_structure + + default_categories_count = default_structure.count + default_fields_count = default_structure.sum { |_, details| details[:attributes]&.count || 0 } + + { + total_categories: current_categories.count, + total_fields: current_categories.sum { |cat| cat.attribute_fields.count }, + custom_categories: custom_categories, + modified_fields: modified_fields, + data_loss_warnings: data_loss_warnings, + filled_attributes_count: filled_attributes_count, + affected_pages_count: affected_pages_count, + will_restore_defaults: !default_structure.empty?, + default_categories_count: default_categories_count, + default_fields_count: default_fields_count, + default_structure: default_structure + } + end + + private + + def load_default_template_structure + # Load the default YAML structure + yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml") + if File.exist?(yaml_path) + YAML.load_file(yaml_path) || {} + else + {} + end + rescue => e + Rails.logger.warn "Could not load default template structure for #{@content_type}: #{e.message}" + {} + end +end \ No newline at end of file diff --git a/app/services/temporary_field_migration_service.rb b/app/services/temporary_field_migration_service.rb deleted file mode 100644 index cdf4b19cd..000000000 --- a/app/services/temporary_field_migration_service.rb +++ /dev/null @@ -1,79 +0,0 @@ -# This service moves attribute values from being directly stored on the model (e.g. Character#age) -# to the new-style of having values in an associated Attribute model. -# Once all data has been moved over, we can remove these old columns and delete this service. -class TemporaryFieldMigrationService < Service - def self.migrate_all_content_for_user(user, force: false) - user.content_list.each do |content| - self.migrate_fields_for_content(content, user, force: force) - end - end - - def self.migrate_fields_for_content(content_model, user, force: false) - return unless content_model.present? && user.present? - return unless content_model.user == user - return if content_model.is_a?(ContentPage) - return if !force && content_model.persisted? && content_model.created_at > 'May 1, 2018'.to_datetime - return if !!content_model.columns_migrated_from_old_style? - - # todo we might be able to do this in a single left outer join - attribute_categories = content_model.class.attribute_categories(content_model.user) - attribute_fields = AttributeField.where(attribute_category_id: attribute_categories.pluck(:id)) - .where.not(field_type: 'link') - .where.not(old_column_source: [nil, ""]) - - # Do a quick check to see if there's exactly 1 existing attribute for each attribute field - # on this model. This lets us avoid a ton of individual "are there any attribute values for - # this field" queries below. - existing_attribute_values_count = Attribute.where( - attribute_field_id: attribute_fields.pluck(:id), - entity_id: content_model.id, - entity_type: content_model.class.name - ).uniq.count - if attribute_fields.count == existing_attribute_values_count - return # hurrah! - end - - # If we're gonna loop over each attribute field, we want to eager_load their attribute values also. - attribute_fields.eager_load(:attribute_values) - attribute_fields.each do |attribute_field| - existing_value = attribute_field.attribute_values.find_by( - entity_id: content_model.id, - entity_type: content_model.class.name - ) - - # If a user has touched this attribute's value since we've created it, - # we don't want to touch it again. - if existing_value && existing_value.created_at != existing_value.updated_at - next - end - if existing_value.try(:value).present? - next - end - - if content_model.respond_to?(attribute_field.old_column_source) - value_from_model = content_model.send(attribute_field.old_column_source) - if value_from_model.present? && value_from_model != existing_value.try(:value) - if existing_value - existing_value.disable_changelog_this_request = true - existing_value.update!(value: value_from_model) - existing_value.disable_changelog_this_request = false - else - new_value = attribute_field.attribute_values.new( - user_id: content_model.user.id, - entity_type: content_model.class.name, - entity_id: content_model.id, - value: value_from_model, - privacy: 'private' # todo just make this the default for the column instead - ) - - new_value.disable_changelog_this_request = true - new_value.save! - new_value.disable_changelog_this_request = true - end - end - end - end - - content_model.update_column(:columns_migrated_from_old_style, true) - end -end diff --git a/app/services/word_count_service.rb b/app/services/word_count_service.rb new file mode 100644 index 000000000..ee25f2f7f --- /dev/null +++ b/app/services/word_count_service.rb @@ -0,0 +1,141 @@ +# Centralized word counting service that provides consistent word counts +# across the entire application (Ruby and JavaScript should match this behavior). +# +# Uses the WordCountAnalyzer gem with specific settings to ensure: +# - Forward slashes split words (path/to/file = 3 words), except in dates +# - Backslashes split words (path\to\file = 3 words) +# - Dotted lines (...) are ignored +# - Dashed lines (---) are ignored +# - Underscores (___) are ignored +# - Stray punctuation is ignored +# - Contractions count as one word (don't = 1 word) +# - Hyphenated words count as one word (well-known = 1 word) +# - Numbers are counted as words +# - HTML/XHTML is stripped before counting +# +# For large documents (>300KB), uses a memory-efficient lightweight counter +# to avoid the ~28MB peak memory usage of the WordCountAnalyzer gem. +class WordCountService + COUNTER_OPTIONS = { + ellipsis: 'ignore', + hyperlink: 'count_as_one', + contraction: 'count_as_one', + hyphenated_word: 'count_as_one', + date: 'no_special_treatment', + number: 'count', + numbered_list: 'ignore', + xhtml: 'remove', + forward_slash: 'count_as_multiple_except_dates', + backslash: 'count_as_multiple', + dotted_line: 'ignore', + dashed_line: 'ignore', + underscore: 'ignore', + stray_punctuation: 'ignore' + }.freeze + + # ~50k words threshold (300KB assumes ~6 bytes per word average) + LARGE_TEXT_THRESHOLD = 300_000 + CHUNK_SIZE = 100_000 + + # Count words in the given text using standardized rules. + # Uses the WordCountAnalyzer gem for small documents and a lightweight + # counter for large documents to reduce memory usage. + # + # @param text [String] The text to count words in (may contain HTML) + # @return [Integer] The word count + def self.count(text) + return 0 if text.blank? + + if text.bytesize < LARGE_TEXT_THRESHOLD + WordCountAnalyzer::Counter.new(**COUNTER_OPTIONS).count(text) + else + count_large_text(text) + end + end + + # Count words with fallback to simple counting on error. + # Use this in background jobs where reliability is more important than + # perfect accuracy. + # + # @param text [String] The text to count words in (may contain HTML) + # @return [Integer] The word count + def self.count_with_fallback(text) + count(text) + rescue => e + Rails.logger.warn("WordCountService: fallback to simple count: #{e.message}") + simple_count(text) + end + + class << self + private + + def count_large_text(text) + cleaned = text.gsub(/<[^>]*>/, ' ') # Strip HTML + + if cleaned.bytesize > CHUNK_SIZE * 3 + count_chunked(cleaned) + else + count_single_pass(cleaned) + end + end + + def count_single_pass(text) + count = 0 + scanner = StringScanner.new(text) + + while !scanner.eos? + scanner.skip(/\s+/) + token = scanner.scan(/\S+/) + break unless token + count += count_token(token) + end + + count + end + + def count_chunked(text) + total = 0 + offset = 0 + + while offset < text.length + chunk_end = [offset + CHUNK_SIZE, text.length].min + + # Find word boundary to avoid splitting words + if chunk_end < text.length + while chunk_end > offset && !text[chunk_end].match?(/\s/) + chunk_end -= 1 + end + chunk_end = offset + CHUNK_SIZE if chunk_end == offset + end + + total += count_single_pass(text[offset...chunk_end]) + offset = chunk_end + end + + total + end + + def count_token(token) + return 0 if token.match?(/\A\.{3,}\z/) # Ellipsis + return 0 if token.match?(/\A-{2,}\z/) # Dashed line + return 0 if token.match?(/\A_{2,}\z/) # Underscore line + return 0 if token.match?(/\A[^\w]+\z/) # Stray punctuation + + if token.include?('/') + return 1 if token.match?(/\A\d{1,2}\/\d{1,2}(\/\d{2,4})?\z/) # Date + return token.split('/').reject(&:empty?).size + end + + if token.include?('\\') + return token.split('\\').reject(&:empty?).size + end + + 1 # Contractions and hyphenated words count as one + end + + def simple_count(text) + return 0 if text.blank? + text.gsub(/<[^>]*>/, ' ').split.size + end + end +end diff --git a/app/views/admin/attributes.html.erb b/app/views/admin/attributes.html.erb index 797cb5f0d..dda059f80 100644 --- a/app/views/admin/attributes.html.erb +++ b/app/views/admin/attributes.html.erb @@ -1,17 +1,77 @@ -+ Overview of custom attribute fields created by users +
Total Attributes
+<%= number_with_delimiter @total_attributes %>
+Users with Attributes
+<%= number_with_delimiter @total_users %>
+Avg per User
+<%= @avg_per_user %>
+No content type data available
+ <% end %>+ New and cancelled subscriptions over time +
++ <%= @days %>-day net: + <%= sprintf "%+d", @reports.limit(@days).sum(:new_monthly_subscriptions) - @reports.limit(@days).sum(:ended_monthly_subscriptions) %> subscriptions -
+ <%= @days %>-day net: + <%= sprintf "%+d", @reports.limit(@days).sum(:new_trimonthly_subscriptions) - @reports.limit(@days).sum(:ended_trimonthly_subscriptions) %> subscriptions -
+ <%= @days %>-day net: + <%= sprintf "%+d", @reports.limit(@days).sum(:new_annual_subscriptions) - @reports.limit(@days).sum(:ended_annual_subscriptions) %> subscriptions -
| Thread | -Trigger words | -Full text | -
|---|---|---|
|
- <%= post.postable.title %> - (<%= link_to post.postable.slug, '/forum/private-topics/' + post.postable.slug %>) - <%= post.postable.created_at.strftime("%m/%d/%Y") %> (<%= time_ago_in_words post.postable.created_at %> ago) - |
- - <% triggers.each do |trigger| %> - <%= trigger %> - <% end %> - | -- <% - content = post.content - triggers.each do |trigger| - content.gsub!(trigger, "#{trigger}") - end - %> - - <%= content.html_safe %> - | -
+ Browse uploaded images across all content
+ <% if params[:content_type].present? %>
+
+ <%= params[:content_type] %>
+
<% end %>
-
- <%= link_to "#{image.content.try(:name).presence || 'Page'} (#{image.content.try(:privacy)})", image.content, class: (image.content.class.try(:color).presence || 'black') + '-text' %>
-
No images found
++ Page <%= params[:page].to_i + 1 %> + <% if @images.any? %> + · Showing <%= @images.size %> images + <% end %> +
++ Analytics for notifications with this reference code +
+Total Sent
+<%= number_with_delimiter @total_sent %>
+Total Clicked
+<%= number_with_delimiter @total_clicked %>
+Click Rate
+<%= @click_rate %>%
+Unique Users
+<%= number_with_delimiter @unique_users %>
+Avg Time to Click
+<%= format_duration(@avg_seconds) %>
+Median Time to Click
+<%= format_duration(@median_seconds) %>
+Fastest Click
+<%= format_duration(@fastest_seconds) %>
+Slowest Click
+<%= format_duration(@slowest_seconds) %>
+First Notification
+<%= @first_notification.created_at.strftime('%B %d, %Y') %>
+<%= time_ago_in_words(@first_notification.created_at) %> ago
+Last Notification
+<%= @last_notification.created_at.strftime('%B %d, %Y') %>
+<%= time_ago_in_words(@last_notification.created_at) %> ago
+Active Period
+ <% + duration_days = ((@last_notification.created_at - @first_notification.created_at) / 1.day).round + if duration_days == 0 + duration_text = "Same day" + elsif duration_days == 1 + duration_text = "1 day" + elsif duration_days < 30 + duration_text = "#{duration_days} days" + elsif duration_days < 365 + duration_text = "#{(duration_days / 30.0).round(1)} months" + else + duration_text = "#{(duration_days / 365.0).round(1)} years" + end + %> +<%= duration_text %>
+| Destination Link | ++ Sent ↓ + | ++ Clicked + | ++ Click Rate + | +
|---|---|---|---|
| + <%= stats.passthrough_link.truncate(60) %> + | +<%= number_with_delimiter total %> | +<%= number_with_delimiter clicked %> | +<%= link_rate %>% | +
<%= raw @sample_notification.message_html %>
+ <% if @sample_notification.passthrough_link.present? %> ++ Links to: <%= @sample_notification.passthrough_link %> +
+ <% end %> ++ Track open rates and engagement across notification campaigns +
+Total Sent
+<%= number_with_delimiter total_count %>
+Total Clicked
+<%= number_with_delimiter clicked_count %>
+Click Rate
+<%= overall_rate %>%
+Avg Time to Click
+<%= avg_time_display %>
+No reference codes found.
+| Destination Link | ++ Sent ↓ + | ++ Clicked + | ++ Click Rate + | +
|---|---|---|---|
| + <%= stats.passthrough_link.truncate(60) %> + | +<%= number_with_delimiter total %> | +<%= number_with_delimiter clicked %> | +<%= rate %>% | +
+ <%= strip_tags(notification.message_html).truncate(80) if notification.message_html.present? %> +
+No recent notifications.
+| - | User ID | -Name | -Last login time | ++ Status + | ++ User ID + | ++ Email + | ++ Name + | ++ Last Login + | |
|---|---|---|---|---|---|---|---|---|---|
| check | -<%= user.id %> | -<%= user.email %> | -<%= user.name %> | -<%= user.last_sign_in_at %> | +|||||
| + + | ++ <%= user.id %> + | ++ <%= user.email %> + | ++ <%= user.name %> + | ++ <%= user.last_sign_in_at&.strftime('%b %d, %Y at %l:%M %p') || 'Never' %> + |
| ID | -Code | -Internal description | -Public description | -Availability | -Active now | -Pages unlocked | -Days active | -
|---|---|---|---|---|---|---|---|
| <%= code.id %> | -<%= code.code %> | -<%= code.internal_description %> | -<%= code.description %> | -
- <% used = code.promotions.pluck(:user_id).uniq.count %>
- <% ratio = (code.uses_remaining * 100.0) / (code.uses_remaining + used) %>
-
-
+
+
+
+
+
+ <%
+ total_codes = @codes.count
+ total_used = @codes.sum { |c| c.promotions.pluck(:user_id).uniq.count }
+ total_active = @codes.sum { |c| c.promotions.active.pluck(:user_id).uniq.count }
+ %>
+
+
+ Promo Codes++ Manage page unlock promotional codes and track their usage + +
+
+
+
+
-
|
- <%= code.promotions.active.pluck(:user_id).uniq.count %> |
- - <% code.page_types.each do |page_type| %> - <% klass = content_class_from_name(page_type) %> - <%= klass.icon %> - <% end %> - | -<%= code.days_active %> days | -
| ID | +Code | +Description | +Availability | +Active Now | +Page Types | +Duration | +
|---|---|---|---|---|---|---|
| + <%= code.id %> + | ++ + <%= code.code %> + + | +
+ <%= code.internal_description %>
+ <% if code.description.present? %>
+ <%= code.description %>
+ <% end %>
+ |
+
+
+
+
+
+
+
+
+
+ <%= used %> used
+ <%= code.uses_remaining %> remaining
+ <%= total %> original
+ |
+ + <% if active_count > 0 %> + + <%= active_count %> + + <% else %> + 0 + <% end %> + | +
+
+ <% code.page_types.each do |page_type| %>
+ <% klass = content_class_from_name(page_type) %>
+
+ <%= page_type %>
+
+ <% end %>
+
+ |
+ + <%= code.days_active %> + days + | +
No promotional codes have been created yet.
+- ID: <%= share.id %> --<% end %> - -<% if @feed.empty? %> - We're all caught up! -<% end %> \ No newline at end of file +
+ <%= pluralize(@feed.total_entries, 'share') %> pending review +
+<%= notice %>
+<%= share.message %>
+<%= share.content_page.name %>
+<%= share.content_page.class.name %>
+No reported shares to review.
+| Thread | -Links | -Images | -Full text | -||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| - <%= post.postable.title %> (<%= post.postable.slug %>) - | -
-
|
-
- <% links.each do |url| %>
- <%= link_to url.first do %>
- <%= image_tag url.first, class: 'left', style: "height: 200px; width: 200px" %>
+
+
+
+
| |||||||||||||
+ Move users to the Starter plan in bulk +
+ Enter one email address per line +
++ This will NOT remove the user from their current plan on Stripe, but WILL send a "You have been unsubscribed" email. +
++ You can also + <%= link_to 'help me improve by rating images', basil_rating_queue_path, class: 'text-white underline decoration-blue-400 hover:decoration-blue-300 underline-offset-2 transition-all hover:text-blue-100' %>. +
+ <% end %> +